From 1ad4a245f31d6913fcb65df9086dd7d09fa28a2c Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 14 Aug 2024 20:07:43 +0200 Subject: [PATCH 1/2] Add support for sub.club support (#2162) * Account tipping * Tryout full flow * Add link params * WIP * Progress flow * Fixes * More progress * Refresh user profile on notification * Tweaks * Fix follow button not refreshing * Refactor proxy url * Code cleanup * Subscribe to a premium account from a standard linked account * Premium posts tab on linked standard account * Fix flow * New domain * Fix flow * More fixes to follow flow * Update so to sub.club * Add colorScheme in URL * rollback domain * Back to sub.club * Use SubClub API for Subscription info * Fix * Merge * Merge branch 'iOS-18' + fixes --- .../xcshareddata/swiftpm/Package.resolved | 9 ++ IceCubesApp/App/SafariRouter.swift | 22 +++ Packages/Account/Package.swift | 2 + .../Account/AccountDetailHeaderView.swift | 52 +++++- .../Sources/Account/AccountDetailView.swift | 9 +- .../Account/AccountDetailViewModel.swift | 86 +++++++++- .../AccountsList/AccountsListRow.swift | 3 +- .../Sources/Account/Follow/FollowButton.swift | 25 +-- .../PremiumAcccountSubsciptionSheetView.swift | 150 ++++++++++++++++++ Packages/Env/Sources/Env/Router.swift | 3 +- .../Sources/Lists/Edit/ListEditView.swift | 3 +- Packages/Models/Sources/Models/Account.swift | 37 +++++ Packages/Models/Sources/Models/App/App.swift | 1 + .../Models/Sources/Models/Relationship.swift | 2 +- .../Models/Sources/Models/SubClubUser.swift | 22 +++ .../Sources/Network/SubClubClient.swift | 36 +++++ .../Sources/StatusKit/Row/StatusRowView.swift | 2 +- .../StatusKit/Row/StatusRowViewModel.swift | 6 +- .../Row/Subviews/StatusRowPremiumView.swift | 18 +++ 19 files changed, 450 insertions(+), 38 deletions(-) create mode 100644 Packages/Account/Sources/Account/Tip/PremiumAcccountSubsciptionSheetView.swift create mode 100644 Packages/Models/Sources/Models/SubClubUser.swift create mode 100644 Packages/Network/Sources/Network/SubClubClient.swift create mode 100644 Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowPremiumView.swift diff --git a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e42e2b04..3224e7d0 100644 --- a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -143,6 +143,15 @@ "revision" : "668a65735751432b640260c56dfa621cec568368", "version" : "1.2.0" } + }, + { + "identity" : "wrappinghstack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/dkk/WrappingHStack", + "state" : { + "revision" : "425d9488ba55f58f0b34498c64c054c77fc2a44b", + "version" : "2.2.11" + } } ], "version" : 2 diff --git a/IceCubesApp/App/SafariRouter.swift b/IceCubesApp/App/SafariRouter.swift index d280ac45..9aac52ab 100644 --- a/IceCubesApp/App/SafariRouter.swift +++ b/IceCubesApp/App/SafariRouter.swift @@ -4,6 +4,8 @@ import Models import Observation import SafariServices import SwiftUI +import AppAccount +import WebKit extension View { @MainActor func withSafariRouter() -> some View { @@ -17,6 +19,7 @@ private struct SafariRouter: ViewModifier { @Environment(Theme.self) private var theme @Environment(UserPreferences.self) private var preferences @Environment(RouterPath.self) private var routerPath + @Environment(AppAccountsManager.self) private var appAccount #if !os(visionOS) @State private var safariManager = InAppSafariManager() @@ -32,6 +35,10 @@ private struct SafariRouter: ViewModifier { .onOpenURL { url in // Open external URL (from icecubesapp://) guard !isSecondaryColumn else { return } + if url.absoluteString == "icecubesapp://subclub" { + safariManager.dismiss() + return + } let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://") guard let url = URL(string: urlString), url.host != nil else { return } _ = routerPath.handleDeepLink(url: url) @@ -47,6 +54,14 @@ private struct SafariRouter: ViewModifier { UIApplication.shared.open(url) return .handled } + } else if url.query()?.contains("callback=") == false, + url.host() == AppInfo.premiumInstance, + let accountName = appAccount.currentAccount.accountName { + let newURL = url.appending(queryItems: [ + .init(name: "callback", value: "icecubesapp://subclub"), + .init(name: "id", value: "@\(accountName)") + ]) + return safariManager.open(newURL) } #if !targetEnvironment(macCatalyst) guard preferences.preferredBrowser == .inAppSafari else { return .systemAction } @@ -101,6 +116,13 @@ private struct SafariRouter: ViewModifier { return .handled } + + func dismiss() { + viewController.presentedViewController?.dismiss(animated: true) + window?.resignKey() + window?.isHidden = false + window = nil + } func setupWindow(windowScene: UIWindowScene) -> UIWindow { let window = window ?? UIWindow(windowScene: windowScene) diff --git a/Packages/Account/Package.swift b/Packages/Account/Package.swift index 05638ddd..c18c6703 100644 --- a/Packages/Account/Package.swift +++ b/Packages/Account/Package.swift @@ -22,6 +22,7 @@ let package = Package( .package(name: "StatusKit", path: "../StatusKit"), .package(name: "Env", path: "../Env"), .package(url: "https://github.com/Dean151/ButtonKit", from: "0.1.1"), + .package(url: "https://github.com/dkk/WrappingHStack", from: "2.2.11"), ], targets: [ .target( @@ -32,6 +33,7 @@ let package = Package( .product(name: "StatusKit", package: "StatusKit"), .product(name: "Env", package: "Env"), .product(name: "ButtonKit", package: "ButtonKit"), + .product(name: "WrappingHStack", package: "WrappingHStack"), ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency"), diff --git a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift index 55404ee8..3d10e467 100644 --- a/Packages/Account/Sources/Account/AccountDetailHeaderView.swift +++ b/Packages/Account/Sources/Account/AccountDetailHeaderView.swift @@ -16,12 +16,15 @@ struct AccountDetailHeaderView: View { @Environment(QuickLook.self) private var quickLook @Environment(RouterPath.self) private var routerPath @Environment(CurrentAccount.self) private var currentAccount + @Environment(StreamWatcher.self) private var watcher @Environment(\.redactionReasons) private var reasons @Environment(\.isSupporter) private var isSupporter: Bool var viewModel: AccountDetailViewModel let account: Account let scrollViewProxy: ScrollViewProxy? + + @State private var isTipSheetPresented: Bool = false var body: some View { VStack(alignment: .leading) { @@ -44,6 +47,20 @@ struct AccountDetailHeaderView: View { accountInfoView Spacer() } + .onChange(of: watcher.latestEvent?.id) { + if let latestEvent = watcher.latestEvent, let latestEvent = latestEvent as? StreamEventNotification { + if latestEvent.notification.account.id == viewModel.accountId || + latestEvent.notification.account.id == viewModel.premiumAccount?.id { + Task { + if viewModel.account?.isLinkedToPremiumAccount == true { + await viewModel.fetchAccount() + } else{ + try? await viewModel.followButtonViewModel?.refreshRelationship() + } + } + } + } + } } private var headerImageView: some View { @@ -210,19 +227,17 @@ struct AccountDetailHeaderView: View { .accessibilityRespondsToUserInteraction(false) movedToView joinedAtView + if viewModel.account?.isPremiumAccount == true && viewModel.relationship?.following == false || viewModel.account?.isLinkedToPremiumAccount == true && viewModel.premiumRelationship?.following == false { + tipView + } } .accessibilityElement(children: .contain) .accessibilitySortPriority(1) Spacer() - if let relationship = viewModel.relationship, !viewModel.isCurrentUser { + if let followButtonViewModel = viewModel.followButtonViewModel, !viewModel.isCurrentUser { HStack { - FollowButton(viewModel: .init(accountId: account.id, - relationship: relationship, - shouldDisplayNotify: true, - relationshipUpdated: { relationship in - viewModel.relationship = relationship - })) + FollowButton(viewModel: followButtonViewModel) } } else if !viewModel.isCurrentUser { ProgressView() @@ -312,6 +327,29 @@ struct AccountDetailHeaderView: View { .accessibilityElement(children: .combine) } } + + @ViewBuilder + private var tipView: some View { + Button { + isTipSheetPresented = true + Task { + if viewModel.account?.isLinkedToPremiumAccount == true { + try? await viewModel.followPremiumAccount() + } + try? await viewModel.followButtonViewModel?.follow() + } + } label: { + Text("$ Subscribe") + } + .buttonStyle(.bordered) + .padding(.top, 8) + .padding(.bottom, 4) + .sheet(isPresented: $isTipSheetPresented) { + if let account = viewModel.account { + PremiumAcccountSubsciptionSheetView(account: account) + } + } + } @ViewBuilder private var movedToView: some View { diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index cc4f269c..ade97871 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -21,7 +21,6 @@ public struct AccountDetailView: View { @Environment(RouterPath.self) private var routerPath @State private var viewModel: AccountDetailViewModel - @State private var isCurrentUser: Bool = false @State private var showBlockConfirmation: Bool = false @State private var isEditingRelationshipNote: Bool = false @State private var showTranslateView: Bool = false @@ -57,8 +56,7 @@ public struct AccountDetailView: View { .applyAccountDetailsRowStyle(theme: theme) Picker("", selection: $viewModel.selectedTab) { - ForEach(isCurrentUser ? AccountDetailViewModel.Tab.currentAccountTabs : AccountDetailViewModel.Tab.accountTabs, - id: \.self) + ForEach(viewModel.tabs, id: \.self) { tab in if tab == .boosts { Image("Rocket") @@ -113,8 +111,7 @@ public struct AccountDetailView: View { } .onAppear { guard reasons != .placeholder else { return } - isCurrentUser = currentAccount.account?.id == viewModel.accountId - viewModel.isCurrentUser = isCurrentUser + viewModel.isCurrentUser = currentAccount.account?.id == viewModel.accountId viewModel.client = client // Avoid capturing non-Sendable `self` just to access the view model. @@ -314,7 +311,7 @@ public struct AccountDetailView: View { } } - if isCurrentUser { + if viewModel.isCurrentUser { Button { routerPath.presentedSheet = .accountEditInfo } label: { diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 679ae511..41695c8e 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -16,7 +16,7 @@ import SwiftUI } enum Tab: Int { - case statuses, favorites, bookmarks, replies, boosts, media + case statuses, favorites, bookmarks, replies, boosts, media, premiumPosts static var currentAccountTabs: [Tab] { [.statuses, .replies, .boosts, .favorites, .bookmarks] @@ -25,6 +25,10 @@ import SwiftUI static var accountTabs: [Tab] { [.statuses, .replies, .boosts, .media] } + + static var premiumAccountTabs: [Tab] { + [.statuses, .premiumPosts, .replies, .boosts, .media] + } var iconName: String { switch self { @@ -34,6 +38,7 @@ import SwiftUI case .replies: "bubble.left.and.bubble.right" case .boosts: "" case .media: "photo.on.rectangle.angled" + case .premiumPosts: "dollarsign" } } @@ -45,9 +50,21 @@ import SwiftUI case .replies: "accessibility.tabs.profile.picker.posts-and-replies" case .boosts: "accessibility.tabs.profile.picker.boosts" case .media: "accessibility.tabs.profile.picker.media" + case .premiumPosts: "Premium Posts" } } } + + + var tabs: [Tab] { + if isCurrentUser { + return Tab.currentAccountTabs + } else if account?.isLinkedToPremiumAccount == true && premiumAccount != nil { + return Tab.premiumAccountTabs + } else { + return Tab.accountTabs + } + } var accountState: AccountState = .loading var statusesState: StatusesState = .loading @@ -61,10 +78,14 @@ import SwiftUI var featuredTags: [FeaturedTag] = [] var fields: [Account.Field] = [] var familiarFollowers: [Account] = [] + + var premiumAccount: Account? + var premiumRelationship: Relationship? + var selectedTab = Tab.statuses { didSet { switch selectedTab { - case .statuses, .replies, .boosts, .media: + case .statuses, .replies, .boosts, .media, .premiumPosts: tabTask?.cancel() tabTask = Task { await fetchNewestStatuses(pullToRefresh: false) @@ -79,7 +100,9 @@ import SwiftUI var translation: Translation? var isLoadingTranslation = false - + + var followButtonViewModel: FollowButtonViewModel? + private(set) var account: Account? private var tabTask: Task? @@ -113,13 +136,27 @@ import SwiftUI guard let client else { return } do { let data = try await fetchAccountData(accountId: accountId, client: client) + accountState = .data(account: data.account) - + try await fetchPremiumAccount(fromAccount: data.account, client: client) account = data.account fields = data.account.fields featuredTags = data.featuredTags featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt } relationship = data.relationships.first + if let relationship { + if let followButtonViewModel { + followButtonViewModel.relationship = relationship + } else { + followButtonViewModel = .init(client: client, + accountId: accountId, + relationship: relationship, + shouldDisplayNotify: true, + relationshipUpdated: { [weak self] relationship in + self?.relationship = relationship + }) + } + } } catch { if let account { accountState = .data(account: account) @@ -159,8 +196,12 @@ import SwiftUI do { statusesState = .loading boosts = [] + var accountIdToFetch = accountId + if selectedTab == .premiumPosts, let accountId = premiumAccount?.id { + accountIdToFetch = accountId + } statuses = - try await client.get(endpoint: Accounts.statuses(id: accountId, + try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch, sinceId: nil, tag: nil, onlyMedia: selectedTab == .media, @@ -197,10 +238,14 @@ import SwiftUI func fetchNextPage() async throws { guard let client else { return } switch selectedTab { - case .statuses, .replies, .boosts, .media: + case .statuses, .replies, .boosts, .media, .premiumPosts: guard let lastId = statuses.last?.id else { return } + var accountIdToFetch = accountId + if selectedTab == .premiumPosts, let accountId = premiumAccount?.id { + accountIdToFetch = accountId + } let newStatuses: [Status] = - try await client.get(endpoint: Accounts.statuses(id: accountId, + try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch, sinceId: lastId, tag: nil, onlyMedia: selectedTab == .media, @@ -239,7 +284,7 @@ import SwiftUI private func reloadTabState() { switch selectedTab { - case .statuses, .replies, .media: + case .statuses, .replies, .media, .premiumPosts: statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) case .boosts: statusesState = .display(statuses: boosts, nextPageState: statuses.count < 20 ? .none : .hasNextPage) @@ -277,3 +322,28 @@ import SwiftUI func statusDidDisappear(status _: Status) {} } + +extension AccountDetailViewModel { + private func fetchPremiumAccount(fromAccount: Account, client: Client) async throws { + if fromAccount.isLinkedToPremiumAccount, let acct = fromAccount.premiumAcct { + let results: SearchResults? = try await client.get(endpoint: Search.search(query: acct, + type: .accounts, + offset: nil, + following: nil), + forceVersion: .v2) + if let premiumAccount = results?.accounts.first { + self.premiumAccount = premiumAccount + let relationships: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [premiumAccount.id])) + self.premiumRelationship = relationships.first + } + } + } + + func followPremiumAccount() async throws { + if let premiumAccount { + premiumRelationship = try await client?.post(endpoint: Accounts.follow(id: premiumAccount.id, + notify: false, + reblogs: true)) + } + } +} diff --git a/Packages/Account/Sources/Account/AccountsList/AccountsListRow.swift b/Packages/Account/Sources/Account/AccountsList/AccountsListRow.swift index 4cc58d73..32299e08 100644 --- a/Packages/Account/Sources/Account/AccountsList/AccountsListRow.swift +++ b/Packages/Account/Sources/Account/AccountsList/AccountsListRow.swift @@ -95,7 +95,8 @@ public struct AccountsListRow: View { let relationShip = viewModel.relationShip { VStack(alignment: .center) { - FollowButton(viewModel: .init(accountId: viewModel.account.id, + FollowButton(viewModel: .init(client: client, + accountId: viewModel.account.id, relationship: relationShip, shouldDisplayNotify: false, relationshipUpdated: { _ in })) diff --git a/Packages/Account/Sources/Account/Follow/FollowButton.swift b/Packages/Account/Sources/Account/Follow/FollowButton.swift index 841c466f..37c0aa21 100644 --- a/Packages/Account/Sources/Account/Follow/FollowButton.swift +++ b/Packages/Account/Sources/Account/Follow/FollowButton.swift @@ -9,18 +9,20 @@ import SwiftUI @MainActor @Observable public class FollowButtonViewModel { - var client: Client? + let client: Client public let accountId: String public let shouldDisplayNotify: Bool public let relationshipUpdated: (Relationship) -> Void - public private(set) var relationship: Relationship + public var relationship: Relationship - public init(accountId: String, + public init(client: Client, + accountId: String, relationship: Relationship, shouldDisplayNotify: Bool, relationshipUpdated: @escaping ((Relationship) -> Void)) { + self.client = client self.accountId = accountId self.relationship = relationship self.shouldDisplayNotify = shouldDisplayNotify @@ -28,7 +30,6 @@ import SwiftUI } func follow() async throws { - guard let client else { return } do { relationship = try await client.post(endpoint: Accounts.follow(id: accountId, notify: false, reblogs: true)) relationshipUpdated(relationship) @@ -38,7 +39,6 @@ import SwiftUI } func unfollow() async throws { - guard let client else { return } do { relationship = try await client.post(endpoint: Accounts.unfollow(id: accountId)) relationshipUpdated(relationship) @@ -46,9 +46,16 @@ import SwiftUI throw error } } + + func refreshRelationship() async throws { + let relationships: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [accountId])) + if let relationship = relationships.first { + self.relationship = relationship + relationshipUpdated(relationship) + } + } func toggleNotify() async throws { - guard let client else { return } do { relationship = try await client.post(endpoint: Accounts.follow(id: accountId, notify: !relationship.notifying, @@ -60,7 +67,6 @@ import SwiftUI } func toggleReboosts() async throws { - guard let client else { return } do { relationship = try await client.post(endpoint: Accounts.follow(id: accountId, notify: relationship.notifying, @@ -83,7 +89,7 @@ public struct FollowButton: View { public var body: some View { VStack(alignment: .trailing) { AsyncButton { - if viewModel.relationship.following { + if viewModel.relationship.following || viewModel.relationship.requested { try await viewModel.unfollow() } else { try await viewModel.follow() @@ -121,8 +127,5 @@ public struct FollowButton: View { } } .buttonStyle(.bordered) - .onAppear { - viewModel.client = client - } } } diff --git a/Packages/Account/Sources/Account/Tip/PremiumAcccountSubsciptionSheetView.swift b/Packages/Account/Sources/Account/Tip/PremiumAcccountSubsciptionSheetView.swift new file mode 100644 index 00000000..6b63cca3 --- /dev/null +++ b/Packages/Account/Sources/Account/Tip/PremiumAcccountSubsciptionSheetView.swift @@ -0,0 +1,150 @@ +import SwiftUI +import Models +import Env +import DesignSystem +import WrappingHStack +import AppAccount +import Network + +@MainActor +struct PremiumAcccountSubsciptionSheetView: View { + @Environment(\.dismiss) private var dismiss + @Environment(Theme.self) private var theme: Theme + @Environment(\.openURL) private var openURL + @Environment(AppAccountsManager.self) private var appAccount: AppAccountsManager + @Environment(\.colorScheme) private var colorScheme + + @State private var isSubscibeSelected: Bool = false + + private enum SheetState: Int, Equatable { + case selection, preparing, webview + } + + @State private var state: SheetState = .selection + @State private var animationsending: Bool = false + @State private var subClubUser: SubClubUser? + + let account: Account + let subClubClient = SubClubClient() + + var body: some View { + VStack { + switch state { + case .selection: + tipView + case .preparing: + preparingView + .transition(.blurReplace) + case .webview: + webView + .transition(.blurReplace) + } + } + .presentationBackground(.thinMaterial) + .presentationCornerRadius(8) + .presentationDetents([.height(330)]) + .task { + if let premiumUsername = account.premiumUsername { + let user = await subClubClient.getUser(username: premiumUsername) + withAnimation { + subClubUser = user + } + } + } + } + + @ViewBuilder + private var tipView: some View { + HStack { + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle") + .font(.title3) + } + .padding(.trailing, 12) + .padding(.top, 8) + } + VStack(alignment: .leading, spacing: 8) { + Text("Subscribe") + .font(.title2) + Text("Subscribe to @\(account.username) to get access to exclusive content!") + if let subscription = subClubUser?.subscription { + Button { + withAnimation(.easeInOut(duration: 0.5)) { + isSubscibeSelected = true + } + } label: { + Text("\(subscription.formattedAmount) / month") + } + .buttonStyle(.borderedProminent) + .padding(.vertical, 8) + } else { + ProgressView() + .foregroundStyle(theme.labelColor) + .padding(.vertical, 8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(theme.secondaryBackgroundColor.opacity(0.4)) + .cornerRadius(8) + .padding(12) + + Spacer() + + if isSubscibeSelected { + Button { + withAnimation { + state = .preparing + } + } label: { + Text("Subscribe") + .font(.headline) + .fontWeight(.bold) + .frame(maxWidth: .infinity) + .frame(height: 40) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 16) + .padding(.bottom, 38) + } + } + + private var preparingView: some View { + Label("Preparing...", systemImage: "wifi") + .symbolEffect(.variableColor.iterative, options: .repeating, value: animationsending) + .font(.title) + .fontWeight(.bold) + .onAppear { + animationsending = true + DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { + dismiss() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation { + state = .webview + } + } + } + } + + private var webView: some View { + VStack(alignment: .center) { + Text("Almost there...") + } + .font(.title) + .fontWeight(.bold) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if let subscription = subClubUser?.subscription, + let accountName = appAccount.currentAccount.accountName, + let premiumUsername = account.premiumUsername, + let url = URL(string: "https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)") { + openURL(url) + } + } + } + } +} diff --git a/Packages/Env/Sources/Env/Router.swift b/Packages/Env/Sources/Env/Router.swift index 15e9f0f7..632483f8 100644 --- a/Packages/Env/Sources/Env/Router.swift +++ b/Packages/Env/Sources/Env/Router.swift @@ -183,7 +183,8 @@ public enum SettingsStartingPoint { { navigate(to: .hashTag(tag: tag, account: nil)) return .handled - } else if url.lastPathComponent.first == "@", + } else if url.lastPathComponent.first == "@" || + (url.host() == AppInfo.premiumInstance && url.pathComponents.contains("users")), let host = url.host, !host.hasPrefix("www") { diff --git a/Packages/Lists/Sources/Lists/Edit/ListEditView.swift b/Packages/Lists/Sources/Lists/Edit/ListEditView.swift index 4e889f1b..cebeb2a5 100644 --- a/Packages/Lists/Sources/Lists/Edit/ListEditView.swift +++ b/Packages/Lists/Sources/Lists/Edit/ListEditView.swift @@ -150,7 +150,8 @@ public struct ListEditView: View { } })) } else { - FollowButton(viewModel: .init(accountId: account.id, + FollowButton(viewModel: .init(client: client, + accountId: account.id, relationship: relationship, shouldDisplayNotify: false, relationshipUpdated: { relationship in diff --git a/Packages/Models/Sources/Models/Account.swift b/Packages/Models/Sources/Models/Account.swift index 4e27bac6..73d8c3cf 100644 --- a/Packages/Models/Sources/Models/Account.swift +++ b/Packages/Models/Sources/Models/Account.swift @@ -71,6 +71,10 @@ public final class Account: Codable, Identifiable, Hashable, Sendable, Equatable header.lastPathComponent != "missing.png" } + public var fullAccountName: String { + "\(acct)@\(url?.host() ?? "")" + } + public init(id: String, username: String, displayName: String?, avatar: URL, header: URL, acct: String, note: HTMLString, createdAt: ServerDate, followersCount: Int, followingCount: Int, statusesCount: Int, lastStatusAt: String? = nil, fields: [Account.Field], locked: Bool, emojis: [Emoji], url: URL? = nil, source: Account.Source? = nil, bot: Bool, discoverable: Bool? = nil, moved: Account? = nil) { self.id = id self.username = username @@ -187,3 +191,36 @@ public struct FamiliarAccounts: Decodable { } extension FamiliarAccounts: Sendable {} + +// Premium Stuff +extension Account { + public var isLinkedToPremiumAccount: Bool { + guard url?.host() != AppInfo.premiumInstance else { + return false + } + return fields.first(where: { $0.value.asRawText.contains(AppInfo.premiumInstance) }) != nil + } + + public var premiumAcct: String? { + if isPremiumAccount { + return "@\(acct)" + } else if let field = fields.first(where: { $0.value.asRawText.hasSuffix(AppInfo.premiumInstance) }) { + return field.value.asRawText + } else if let field = fields.first(where: { $0.value.asRawText.hasPrefix("https://\(AppInfo.premiumInstance)") }), + let url = URL(string: field.value.asRawText) { + return "\(url.lastPathComponent)@\(url.host() ?? "\(AppInfo.premiumInstance)")" + } + return nil + } + + public var premiumUsername: String? { + var username = premiumAcct?.replacingOccurrences(of: "@\(AppInfo.premiumInstance)", with: "") + username?.removeFirst() + return username + } + + public var isPremiumAccount: Bool { + url?.host() == AppInfo.premiumInstance + } + +} diff --git a/Packages/Models/Sources/Models/App/App.swift b/Packages/Models/Sources/Models/App/App.swift index dfde8390..149e6280 100644 --- a/Packages/Models/Sources/Models/App/App.swift +++ b/Packages/Models/Sources/Models/App/App.swift @@ -9,4 +9,5 @@ public enum AppInfo { public static let revenueCatKey = "appl_JXmiRckOzXXTsHKitQiicXCvMQi" public static let defaultServer = "mastodon.social" public static let keychainGroup = "346J38YKE3.com.thomasricouard.IceCubesApp" + public static let premiumInstance = "sub.club" } diff --git a/Packages/Models/Sources/Models/Relationship.swift b/Packages/Models/Sources/Models/Relationship.swift index 7dbf8f60..04359fc5 100644 --- a/Packages/Models/Sources/Models/Relationship.swift +++ b/Packages/Models/Sources/Models/Relationship.swift @@ -1,6 +1,6 @@ import Foundation -public struct Relationship: Codable { +public struct Relationship: Codable, Equatable, Identifiable { public let id: String public let following: Bool public let showingReblogs: Bool diff --git a/Packages/Models/Sources/Models/SubClubUser.swift b/Packages/Models/Sources/Models/SubClubUser.swift new file mode 100644 index 00000000..db5d142e --- /dev/null +++ b/Packages/Models/Sources/Models/SubClubUser.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct SubClubUser: Sendable, Identifiable, Decodable { + public struct Subscription: Sendable, Decodable { + public let paymentType: String + public let currency: String + public let interval: String + public let intervalCount: Int + public let unitAmount: Int + + public var formattedAmount: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = Locale(identifier: "en_US") + formatter.maximumFractionDigits = 0 + return formatter.string(from: .init(integerLiteral: unitAmount / 100)) ?? "$NaN" + } + } + + public let id: String + public let subscription: Subscription? +} diff --git a/Packages/Network/Sources/Network/SubClubClient.swift b/Packages/Network/Sources/Network/SubClubClient.swift new file mode 100644 index 00000000..19e14c68 --- /dev/null +++ b/Packages/Network/Sources/Network/SubClubClient.swift @@ -0,0 +1,36 @@ +import Foundation +import Models + +public struct SubClubClient: Sendable { + public enum Endpoint { + case user(username: String) + + var path: String { + switch self { + case .user(let username): + return "users/\(username)" + } + } + } + + public init() { } + + private var url: String { + "https://\(AppInfo.premiumInstance)/" + } + + public func getUser(username: String) async -> SubClubUser? { + guard let url = URL(string: url.appending(Endpoint.user(username: username).path)) else { + return nil + } + let request = URLRequest(url: url) + do { + let (result, _) = try await URLSession.shared.data(for: request) + let decoder = JSONDecoder() + let user = try decoder.decode(SubClubUser.self, from: result) + return user + } catch { + return nil + } + } +} diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift index 0d2d2359..02abd00a 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift @@ -18,7 +18,6 @@ public struct StatusRowView: View { @Environment(\.isHomeTimeline) private var isHomeTimeline @Environment(RouterPath.self) private var routerPath: RouterPath - @Environment(QuickLook.self) private var quickLook @Environment(Theme.self) private var theme @Environment(Client.self) private var client @@ -64,6 +63,7 @@ public struct StatusRowView: View { } else { if !isCompact && context != .detail { Group { + StatusRowPremiumView(viewModel: viewModel) StatusRowTagView(viewModel: viewModel) StatusRowReblogView(viewModel: viewModel) StatusRowReplyView(viewModel: viewModel) diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift index 9b1c86c3..19b77974 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift @@ -126,7 +126,9 @@ import SwiftUI } else if userMentionned { theme.secondaryBackgroundColor } else { - if userFollowedTag != nil { + if status.account.isPremiumAccount { + makeDecorativeGradient(startColor: .yellow, endColor: theme.primaryBackgroundColor) + } else if userFollowedTag != nil { makeDecorativeGradient(startColor: .teal, endColor: theme.primaryBackgroundColor) } else if status.reblog != nil { makeDecorativeGradient(startColor: theme.tintColor, endColor: theme.primaryBackgroundColor) @@ -142,6 +144,8 @@ import SwiftUI theme.tintColor.opacity(0.15) } else if userMentionned { theme.secondaryBackgroundColor + } else if status.account.isPremiumAccount { + Color.yellow.opacity(0.4) } else { theme.primaryBackgroundColor } diff --git a/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowPremiumView.swift b/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowPremiumView.swift new file mode 100644 index 00000000..248336bd --- /dev/null +++ b/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowPremiumView.swift @@ -0,0 +1,18 @@ +import DesignSystem +import Env +import SwiftUI + +struct StatusRowPremiumView: View { + @Environment(\.isHomeTimeline) private var isHomeTimeline + + let viewModel: StatusRowViewModel + + var body: some View { + if isHomeTimeline, viewModel.status.account.isPremiumAccount { + Text("From a subscribed premium account") + .font(.scaledFootnote) + .foregroundStyle(.secondary) + .fontWeight(.semibold) + } + } +} From dd1a4585e0f08045191a0455e2df71642eb9fb74 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 14 Aug 2024 20:19:20 +0200 Subject: [PATCH 2/2] Fix build on visionOS --- IceCubesApp/App/SafariRouter.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/IceCubesApp/App/SafariRouter.swift b/IceCubesApp/App/SafariRouter.swift index 9aac52ab..0cc3cd98 100644 --- a/IceCubesApp/App/SafariRouter.swift +++ b/IceCubesApp/App/SafariRouter.swift @@ -36,7 +36,9 @@ private struct SafariRouter: ViewModifier { // Open external URL (from icecubesapp://) guard !isSecondaryColumn else { return } if url.absoluteString == "icecubesapp://subclub" { + #if !os(visionOS) safariManager.dismiss() + #endif return } let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://") @@ -61,7 +63,12 @@ private struct SafariRouter: ViewModifier { .init(name: "callback", value: "icecubesapp://subclub"), .init(name: "id", value: "@\(accountName)") ]) + + #if !os(visionOS) return safariManager.open(newURL) + #else + return .systemAction + #endif } #if !targetEnvironment(macCatalyst) guard preferences.preferredBrowser == .inAppSafari else { return .systemAction }