mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-25 09:41:02 +00:00
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:
parent
9d11814e49
commit
1ad4a245f3
19 changed files with 450 additions and 38 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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<Void, Never>?
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
22
Packages/Models/Sources/Models/SubClubUser.swift
Normal file
22
Packages/Models/Sources/Models/SubClubUser.swift
Normal 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?
|
||||
}
|
36
Packages/Network/Sources/Network/SubClubClient.swift
Normal file
36
Packages/Network/Sources/Network/SubClubClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue