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
This commit is contained in:
Thomas Ricouard 2024-08-14 20:07:43 +02:00 committed by GitHub
parent 9d11814e49
commit 1ad4a245f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 450 additions and 38 deletions

View file

@ -143,6 +143,15 @@
"revision" : "668a65735751432b640260c56dfa621cec568368", "revision" : "668a65735751432b640260c56dfa621cec568368",
"version" : "1.2.0" "version" : "1.2.0"
} }
},
{
"identity" : "wrappinghstack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/dkk/WrappingHStack",
"state" : {
"revision" : "425d9488ba55f58f0b34498c64c054c77fc2a44b",
"version" : "2.2.11"
}
} }
], ],
"version" : 2 "version" : 2

View file

@ -4,6 +4,8 @@ import Models
import Observation import Observation
import SafariServices import SafariServices
import SwiftUI import SwiftUI
import AppAccount
import WebKit
extension View { extension View {
@MainActor func withSafariRouter() -> some View { @MainActor func withSafariRouter() -> some View {
@ -17,6 +19,7 @@ private struct SafariRouter: ViewModifier {
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var preferences @Environment(UserPreferences.self) private var preferences
@Environment(RouterPath.self) private var routerPath @Environment(RouterPath.self) private var routerPath
@Environment(AppAccountsManager.self) private var appAccount
#if !os(visionOS) #if !os(visionOS)
@State private var safariManager = InAppSafariManager() @State private var safariManager = InAppSafariManager()
@ -32,6 +35,10 @@ private struct SafariRouter: ViewModifier {
.onOpenURL { url in .onOpenURL { url in
// Open external URL (from icecubesapp://) // Open external URL (from icecubesapp://)
guard !isSecondaryColumn else { return } guard !isSecondaryColumn else { return }
if url.absoluteString == "icecubesapp://subclub" {
safariManager.dismiss()
return
}
let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://") let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://")
guard let url = URL(string: urlString), url.host != nil else { return } guard let url = URL(string: urlString), url.host != nil else { return }
_ = routerPath.handleDeepLink(url: url) _ = routerPath.handleDeepLink(url: url)
@ -47,6 +54,14 @@ private struct SafariRouter: ViewModifier {
UIApplication.shared.open(url) UIApplication.shared.open(url)
return .handled 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) #if !targetEnvironment(macCatalyst)
guard preferences.preferredBrowser == .inAppSafari else { return .systemAction } guard preferences.preferredBrowser == .inAppSafari else { return .systemAction }
@ -102,6 +117,13 @@ private struct SafariRouter: ViewModifier {
return .handled return .handled
} }
func dismiss() {
viewController.presentedViewController?.dismiss(animated: true)
window?.resignKey()
window?.isHidden = false
window = nil
}
func setupWindow(windowScene: UIWindowScene) -> UIWindow { func setupWindow(windowScene: UIWindowScene) -> UIWindow {
let window = window ?? UIWindow(windowScene: windowScene) let window = window ?? UIWindow(windowScene: windowScene)

View file

@ -22,6 +22,7 @@ let package = Package(
.package(name: "StatusKit", path: "../StatusKit"), .package(name: "StatusKit", path: "../StatusKit"),
.package(name: "Env", path: "../Env"), .package(name: "Env", path: "../Env"),
.package(url: "https://github.com/Dean151/ButtonKit", from: "0.1.1"), .package(url: "https://github.com/Dean151/ButtonKit", from: "0.1.1"),
.package(url: "https://github.com/dkk/WrappingHStack", from: "2.2.11"),
], ],
targets: [ targets: [
.target( .target(
@ -32,6 +33,7 @@ let package = Package(
.product(name: "StatusKit", package: "StatusKit"), .product(name: "StatusKit", package: "StatusKit"),
.product(name: "Env", package: "Env"), .product(name: "Env", package: "Env"),
.product(name: "ButtonKit", package: "ButtonKit"), .product(name: "ButtonKit", package: "ButtonKit"),
.product(name: "WrappingHStack", package: "WrappingHStack"),
], ],
swiftSettings: [ swiftSettings: [
.enableExperimentalFeature("StrictConcurrency"), .enableExperimentalFeature("StrictConcurrency"),

View file

@ -16,6 +16,7 @@ struct AccountDetailHeaderView: View {
@Environment(QuickLook.self) private var quickLook @Environment(QuickLook.self) private var quickLook
@Environment(RouterPath.self) private var routerPath @Environment(RouterPath.self) private var routerPath
@Environment(CurrentAccount.self) private var currentAccount @Environment(CurrentAccount.self) private var currentAccount
@Environment(StreamWatcher.self) private var watcher
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@Environment(\.isSupporter) private var isSupporter: Bool @Environment(\.isSupporter) private var isSupporter: Bool
@ -23,6 +24,8 @@ struct AccountDetailHeaderView: View {
let account: Account let account: Account
let scrollViewProxy: ScrollViewProxy? let scrollViewProxy: ScrollViewProxy?
@State private var isTipSheetPresented: Bool = false
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
@ -44,6 +47,20 @@ struct AccountDetailHeaderView: View {
accountInfoView accountInfoView
Spacer() 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 { private var headerImageView: some View {
@ -210,19 +227,17 @@ struct AccountDetailHeaderView: View {
.accessibilityRespondsToUserInteraction(false) .accessibilityRespondsToUserInteraction(false)
movedToView movedToView
joinedAtView joinedAtView
if viewModel.account?.isPremiumAccount == true && viewModel.relationship?.following == false || viewModel.account?.isLinkedToPremiumAccount == true && viewModel.premiumRelationship?.following == false {
tipView
}
} }
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilitySortPriority(1) .accessibilitySortPriority(1)
Spacer() Spacer()
if let relationship = viewModel.relationship, !viewModel.isCurrentUser { if let followButtonViewModel = viewModel.followButtonViewModel, !viewModel.isCurrentUser {
HStack { HStack {
FollowButton(viewModel: .init(accountId: account.id, FollowButton(viewModel: followButtonViewModel)
relationship: relationship,
shouldDisplayNotify: true,
relationshipUpdated: { relationship in
viewModel.relationship = relationship
}))
} }
} else if !viewModel.isCurrentUser { } else if !viewModel.isCurrentUser {
ProgressView() ProgressView()
@ -313,6 +328,29 @@ struct AccountDetailHeaderView: View {
} }
} }
@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 @ViewBuilder
private var movedToView: some View { private var movedToView: some View {
if let movedTo = viewModel.account?.moved { if let movedTo = viewModel.account?.moved {

View file

@ -21,7 +21,6 @@ public struct AccountDetailView: View {
@Environment(RouterPath.self) private var routerPath @Environment(RouterPath.self) private var routerPath
@State private var viewModel: AccountDetailViewModel @State private var viewModel: AccountDetailViewModel
@State private var isCurrentUser: Bool = false
@State private var showBlockConfirmation: Bool = false @State private var showBlockConfirmation: Bool = false
@State private var isEditingRelationshipNote: Bool = false @State private var isEditingRelationshipNote: Bool = false
@State private var showTranslateView: Bool = false @State private var showTranslateView: Bool = false
@ -57,8 +56,7 @@ public struct AccountDetailView: View {
.applyAccountDetailsRowStyle(theme: theme) .applyAccountDetailsRowStyle(theme: theme)
Picker("", selection: $viewModel.selectedTab) { Picker("", selection: $viewModel.selectedTab) {
ForEach(isCurrentUser ? AccountDetailViewModel.Tab.currentAccountTabs : AccountDetailViewModel.Tab.accountTabs, ForEach(viewModel.tabs, id: \.self)
id: \.self)
{ tab in { tab in
if tab == .boosts { if tab == .boosts {
Image("Rocket") Image("Rocket")
@ -113,8 +111,7 @@ public struct AccountDetailView: View {
} }
.onAppear { .onAppear {
guard reasons != .placeholder else { return } guard reasons != .placeholder else { return }
isCurrentUser = currentAccount.account?.id == viewModel.accountId viewModel.isCurrentUser = currentAccount.account?.id == viewModel.accountId
viewModel.isCurrentUser = isCurrentUser
viewModel.client = client viewModel.client = client
// Avoid capturing non-Sendable `self` just to access the view model. // 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 { Button {
routerPath.presentedSheet = .accountEditInfo routerPath.presentedSheet = .accountEditInfo
} label: { } label: {

View file

@ -16,7 +16,7 @@ import SwiftUI
} }
enum Tab: Int { enum Tab: Int {
case statuses, favorites, bookmarks, replies, boosts, media case statuses, favorites, bookmarks, replies, boosts, media, premiumPosts
static var currentAccountTabs: [Tab] { static var currentAccountTabs: [Tab] {
[.statuses, .replies, .boosts, .favorites, .bookmarks] [.statuses, .replies, .boosts, .favorites, .bookmarks]
@ -26,6 +26,10 @@ import SwiftUI
[.statuses, .replies, .boosts, .media] [.statuses, .replies, .boosts, .media]
} }
static var premiumAccountTabs: [Tab] {
[.statuses, .premiumPosts, .replies, .boosts, .media]
}
var iconName: String { var iconName: String {
switch self { switch self {
case .statuses: "bubble.right" case .statuses: "bubble.right"
@ -34,6 +38,7 @@ import SwiftUI
case .replies: "bubble.left.and.bubble.right" case .replies: "bubble.left.and.bubble.right"
case .boosts: "" case .boosts: ""
case .media: "photo.on.rectangle.angled" case .media: "photo.on.rectangle.angled"
case .premiumPosts: "dollarsign"
} }
} }
@ -45,10 +50,22 @@ import SwiftUI
case .replies: "accessibility.tabs.profile.picker.posts-and-replies" case .replies: "accessibility.tabs.profile.picker.posts-and-replies"
case .boosts: "accessibility.tabs.profile.picker.boosts" case .boosts: "accessibility.tabs.profile.picker.boosts"
case .media: "accessibility.tabs.profile.picker.media" 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 accountState: AccountState = .loading
var statusesState: StatusesState = .loading var statusesState: StatusesState = .loading
@ -61,10 +78,14 @@ import SwiftUI
var featuredTags: [FeaturedTag] = [] var featuredTags: [FeaturedTag] = []
var fields: [Account.Field] = [] var fields: [Account.Field] = []
var familiarFollowers: [Account] = [] var familiarFollowers: [Account] = []
var premiumAccount: Account?
var premiumRelationship: Relationship?
var selectedTab = Tab.statuses { var selectedTab = Tab.statuses {
didSet { didSet {
switch selectedTab { switch selectedTab {
case .statuses, .replies, .boosts, .media: case .statuses, .replies, .boosts, .media, .premiumPosts:
tabTask?.cancel() tabTask?.cancel()
tabTask = Task { tabTask = Task {
await fetchNewestStatuses(pullToRefresh: false) await fetchNewestStatuses(pullToRefresh: false)
@ -80,6 +101,8 @@ import SwiftUI
var translation: Translation? var translation: Translation?
var isLoadingTranslation = false var isLoadingTranslation = false
var followButtonViewModel: FollowButtonViewModel?
private(set) var account: Account? private(set) var account: Account?
private var tabTask: Task<Void, Never>? private var tabTask: Task<Void, Never>?
@ -113,13 +136,27 @@ import SwiftUI
guard let client else { return } guard let client else { return }
do { do {
let data = try await fetchAccountData(accountId: accountId, client: client) let data = try await fetchAccountData(accountId: accountId, client: client)
accountState = .data(account: data.account)
accountState = .data(account: data.account)
try await fetchPremiumAccount(fromAccount: data.account, client: client)
account = data.account account = data.account
fields = data.account.fields fields = data.account.fields
featuredTags = data.featuredTags featuredTags = data.featuredTags
featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt } featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt }
relationship = data.relationships.first 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 { } catch {
if let account { if let account {
accountState = .data(account: account) accountState = .data(account: account)
@ -159,8 +196,12 @@ import SwiftUI
do { do {
statusesState = .loading statusesState = .loading
boosts = [] boosts = []
var accountIdToFetch = accountId
if selectedTab == .premiumPosts, let accountId = premiumAccount?.id {
accountIdToFetch = accountId
}
statuses = statuses =
try await client.get(endpoint: Accounts.statuses(id: accountId, try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch,
sinceId: nil, sinceId: nil,
tag: nil, tag: nil,
onlyMedia: selectedTab == .media, onlyMedia: selectedTab == .media,
@ -197,10 +238,14 @@ import SwiftUI
func fetchNextPage() async throws { func fetchNextPage() async throws {
guard let client else { return } guard let client else { return }
switch selectedTab { switch selectedTab {
case .statuses, .replies, .boosts, .media: case .statuses, .replies, .boosts, .media, .premiumPosts:
guard let lastId = statuses.last?.id else { return } guard let lastId = statuses.last?.id else { return }
var accountIdToFetch = accountId
if selectedTab == .premiumPosts, let accountId = premiumAccount?.id {
accountIdToFetch = accountId
}
let newStatuses: [Status] = let newStatuses: [Status] =
try await client.get(endpoint: Accounts.statuses(id: accountId, try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch,
sinceId: lastId, sinceId: lastId,
tag: nil, tag: nil,
onlyMedia: selectedTab == .media, onlyMedia: selectedTab == .media,
@ -239,7 +284,7 @@ import SwiftUI
private func reloadTabState() { private func reloadTabState() {
switch selectedTab { switch selectedTab {
case .statuses, .replies, .media: case .statuses, .replies, .media, .premiumPosts:
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
case .boosts: case .boosts:
statusesState = .display(statuses: boosts, nextPageState: statuses.count < 20 ? .none : .hasNextPage) statusesState = .display(statuses: boosts, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
@ -277,3 +322,28 @@ import SwiftUI
func statusDidDisappear(status _: Status) {} 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))
}
}
}

View file

@ -95,7 +95,8 @@ public struct AccountsListRow: View {
let relationShip = viewModel.relationShip let relationShip = viewModel.relationShip
{ {
VStack(alignment: .center) { VStack(alignment: .center) {
FollowButton(viewModel: .init(accountId: viewModel.account.id, FollowButton(viewModel: .init(client: client,
accountId: viewModel.account.id,
relationship: relationShip, relationship: relationShip,
shouldDisplayNotify: false, shouldDisplayNotify: false,
relationshipUpdated: { _ in })) relationshipUpdated: { _ in }))

View file

@ -9,18 +9,20 @@ import SwiftUI
@MainActor @MainActor
@Observable public class FollowButtonViewModel { @Observable public class FollowButtonViewModel {
var client: Client? let client: Client
public let accountId: String public let accountId: String
public let shouldDisplayNotify: Bool public let shouldDisplayNotify: Bool
public let relationshipUpdated: (Relationship) -> Void 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, relationship: Relationship,
shouldDisplayNotify: Bool, shouldDisplayNotify: Bool,
relationshipUpdated: @escaping ((Relationship) -> Void)) relationshipUpdated: @escaping ((Relationship) -> Void))
{ {
self.client = client
self.accountId = accountId self.accountId = accountId
self.relationship = relationship self.relationship = relationship
self.shouldDisplayNotify = shouldDisplayNotify self.shouldDisplayNotify = shouldDisplayNotify
@ -28,7 +30,6 @@ import SwiftUI
} }
func follow() async throws { func follow() async throws {
guard let client else { return }
do { do {
relationship = try await client.post(endpoint: Accounts.follow(id: accountId, notify: false, reblogs: true)) relationship = try await client.post(endpoint: Accounts.follow(id: accountId, notify: false, reblogs: true))
relationshipUpdated(relationship) relationshipUpdated(relationship)
@ -38,7 +39,6 @@ import SwiftUI
} }
func unfollow() async throws { func unfollow() async throws {
guard let client else { return }
do { do {
relationship = try await client.post(endpoint: Accounts.unfollow(id: accountId)) relationship = try await client.post(endpoint: Accounts.unfollow(id: accountId))
relationshipUpdated(relationship) relationshipUpdated(relationship)
@ -47,8 +47,15 @@ import SwiftUI
} }
} }
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 { func toggleNotify() async throws {
guard let client else { return }
do { do {
relationship = try await client.post(endpoint: Accounts.follow(id: accountId, relationship = try await client.post(endpoint: Accounts.follow(id: accountId,
notify: !relationship.notifying, notify: !relationship.notifying,
@ -60,7 +67,6 @@ import SwiftUI
} }
func toggleReboosts() async throws { func toggleReboosts() async throws {
guard let client else { return }
do { do {
relationship = try await client.post(endpoint: Accounts.follow(id: accountId, relationship = try await client.post(endpoint: Accounts.follow(id: accountId,
notify: relationship.notifying, notify: relationship.notifying,
@ -83,7 +89,7 @@ public struct FollowButton: View {
public var body: some View { public var body: some View {
VStack(alignment: .trailing) { VStack(alignment: .trailing) {
AsyncButton { AsyncButton {
if viewModel.relationship.following { if viewModel.relationship.following || viewModel.relationship.requested {
try await viewModel.unfollow() try await viewModel.unfollow()
} else { } else {
try await viewModel.follow() try await viewModel.follow()
@ -121,8 +127,5 @@ public struct FollowButton: View {
} }
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.onAppear {
viewModel.client = client
}
} }
} }

View file

@ -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)&currency=\(subscription.currency)&theme=\(colorScheme)") {
openURL(url)
}
}
}
}
}

View file

@ -183,7 +183,8 @@ public enum SettingsStartingPoint {
{ {
navigate(to: .hashTag(tag: tag, account: nil)) navigate(to: .hashTag(tag: tag, account: nil))
return .handled return .handled
} else if url.lastPathComponent.first == "@", } else if url.lastPathComponent.first == "@" ||
(url.host() == AppInfo.premiumInstance && url.pathComponents.contains("users")),
let host = url.host, let host = url.host,
!host.hasPrefix("www") !host.hasPrefix("www")
{ {

View file

@ -150,7 +150,8 @@ public struct ListEditView: View {
} }
})) }))
} else { } else {
FollowButton(viewModel: .init(accountId: account.id, FollowButton(viewModel: .init(client: client,
accountId: account.id,
relationship: relationship, relationship: relationship,
shouldDisplayNotify: false, shouldDisplayNotify: false,
relationshipUpdated: { relationship in relationshipUpdated: { relationship in

View file

@ -71,6 +71,10 @@ public final class Account: Codable, Identifiable, Hashable, Sendable, Equatable
header.lastPathComponent != "missing.png" 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) { 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.id = id
self.username = username self.username = username
@ -187,3 +191,36 @@ public struct FamiliarAccounts: Decodable {
} }
extension FamiliarAccounts: Sendable {} 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
}
}

View file

@ -9,4 +9,5 @@ public enum AppInfo {
public static let revenueCatKey = "appl_JXmiRckOzXXTsHKitQiicXCvMQi" public static let revenueCatKey = "appl_JXmiRckOzXXTsHKitQiicXCvMQi"
public static let defaultServer = "mastodon.social" public static let defaultServer = "mastodon.social"
public static let keychainGroup = "346J38YKE3.com.thomasricouard.IceCubesApp" public static let keychainGroup = "346J38YKE3.com.thomasricouard.IceCubesApp"
public static let premiumInstance = "sub.club"
} }

View file

@ -1,6 +1,6 @@
import Foundation import Foundation
public struct Relationship: Codable { public struct Relationship: Codable, Equatable, Identifiable {
public let id: String public let id: String
public let following: Bool public let following: Bool
public let showingReblogs: Bool public let showingReblogs: Bool

View file

@ -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?
}

View file

@ -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
}
}
}

View file

@ -18,7 +18,6 @@ public struct StatusRowView: View {
@Environment(\.isHomeTimeline) private var isHomeTimeline @Environment(\.isHomeTimeline) private var isHomeTimeline
@Environment(RouterPath.self) private var routerPath: RouterPath @Environment(RouterPath.self) private var routerPath: RouterPath
@Environment(QuickLook.self) private var quickLook @Environment(QuickLook.self) private var quickLook
@Environment(Theme.self) private var theme @Environment(Theme.self) private var theme
@Environment(Client.self) private var client @Environment(Client.self) private var client
@ -64,6 +63,7 @@ public struct StatusRowView: View {
} else { } else {
if !isCompact && context != .detail { if !isCompact && context != .detail {
Group { Group {
StatusRowPremiumView(viewModel: viewModel)
StatusRowTagView(viewModel: viewModel) StatusRowTagView(viewModel: viewModel)
StatusRowReblogView(viewModel: viewModel) StatusRowReblogView(viewModel: viewModel)
StatusRowReplyView(viewModel: viewModel) StatusRowReplyView(viewModel: viewModel)

View file

@ -126,7 +126,9 @@ import SwiftUI
} else if userMentionned { } else if userMentionned {
theme.secondaryBackgroundColor theme.secondaryBackgroundColor
} else { } else {
if userFollowedTag != nil { if status.account.isPremiumAccount {
makeDecorativeGradient(startColor: .yellow, endColor: theme.primaryBackgroundColor)
} else if userFollowedTag != nil {
makeDecorativeGradient(startColor: .teal, endColor: theme.primaryBackgroundColor) makeDecorativeGradient(startColor: .teal, endColor: theme.primaryBackgroundColor)
} else if status.reblog != nil { } else if status.reblog != nil {
makeDecorativeGradient(startColor: theme.tintColor, endColor: theme.primaryBackgroundColor) makeDecorativeGradient(startColor: theme.tintColor, endColor: theme.primaryBackgroundColor)
@ -142,6 +144,8 @@ import SwiftUI
theme.tintColor.opacity(0.15) theme.tintColor.opacity(0.15)
} else if userMentionned { } else if userMentionned {
theme.secondaryBackgroundColor theme.secondaryBackgroundColor
} else if status.account.isPremiumAccount {
Color.yellow.opacity(0.4)
} else { } else {
theme.primaryBackgroundColor theme.primaryBackgroundColor
} }

View file

@ -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)
}
}
}