import Env import Models import Network import Status import SwiftUI @MainActor class AccountDetailViewModel: ObservableObject, StatusesFetcher { let accountId: String var client: Client? var isCurrentUser: Bool = false enum AccountState { case loading, data(account: Account), error(error: Error) } enum Tab: Int { case statuses, favourites, bookmarks, followedTags, postsAndReplies, media, lists static var currentAccountTabs: [Tab] { [.statuses, .favourites, .bookmarks, .followedTags, .lists] } static var accountTabs: [Tab] { [.statuses, .postsAndReplies, .media] } var iconName: String { switch self { case .statuses: return "bubble.right" case .favourites: return "star" case .bookmarks: return "bookmark" case .followedTags: return "tag" case .postsAndReplies: return "bubble.left.and.bubble.right" case .media: return "photo.on.rectangle.angled" case .lists: return "list.bullet" } } } enum TabState { case followedTags case statuses(statusesState: StatusesState) case lists } @Published var accountState: AccountState = .loading @Published var tabState: TabState = .statuses(statusesState: .loading) { didSet { /// Forward viewModel tabState related to statusesState to statusesState property /// for `StatusesFetcher` conformance as we wrap StatusesState in TabState switch tabState { case let .statuses(statusesState): self.statusesState = statusesState default: break } } } @Published var statusesState: StatusesState = .loading @Published var relationship: Relationshionship? @Published var pinned: [Status] = [] @Published var favourites: [Status] = [] @Published var bookmarks: [Status] = [] private var favouritesNextPage: LinkHandler? private var bookmarksNextPage: LinkHandler? @Published var featuredTags: [FeaturedTag] = [] @Published var fields: [Account.Field] = [] @Published var familliarFollowers: [Account] = [] @Published var selectedTab = Tab.statuses { didSet { switch selectedTab { case .statuses, .postsAndReplies, .media: tabTask?.cancel() tabTask = Task { await fetchStatuses() } default: reloadTabState() } } } private(set) var account: Account? private var tabTask: Task? private(set) var statuses: [Status] = [] /// When coming from a URL like a mention tap in a status. init(accountId: String) { self.accountId = accountId isCurrentUser = false } /// When the account is already fetched by the parent caller. init(account: Account) { accountId = account.id self.account = account accountState = .data(account: account) } struct AccountData { let account: Account let featuredTags: [FeaturedTag] let relationships: [Relationshionship] let familliarFollowers: [FamilliarAccounts] } func fetchAccount() async { guard let client else { return } do { let data = try await fetchAccountData(accountId: accountId, client: client) accountState = .data(account: data.account) account = data.account fields = data.account.fields featuredTags = data.featuredTags featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt } relationship = data.relationships.first familliarFollowers = data.familliarFollowers.first?.accounts ?? [] } catch { if let account { accountState = .data(account: account) } else { accountState = .error(error: error) } } } func fetchAccountData(accountId: String, client: Client) async throws -> AccountData { async let account: Account = client.get(endpoint: Accounts.accounts(id: accountId)) async let featuredTags: [FeaturedTag] = client.get(endpoint: Accounts.featuredTags(id: accountId)) if client.isAuth && !isCurrentUser { async let relationships: [Relationshionship] = client.get(endpoint: Accounts.relationships(ids: [accountId])) async let familliarFollowers: [FamilliarAccounts] = client.get(endpoint: Accounts.familiarFollowers(withAccount: accountId)) return try await .init(account: account, featuredTags: featuredTags, relationships: relationships, familliarFollowers: familliarFollowers) } return try await .init(account: account, featuredTags: featuredTags, relationships: [], familliarFollowers: []) } func fetchStatuses() async { guard let client else { return } do { tabState = .statuses(statusesState: .loading) statuses = try await client.get(endpoint: Accounts.statuses(id: accountId, sinceId: nil, tag: nil, onlyMedia: selectedTab == .media ? true : nil, excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil, pinned: nil)) if selectedTab == .statuses { pinned = try await client.get(endpoint: Accounts.statuses(id: accountId, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: true)) } if isCurrentUser { (favourites, favouritesNextPage) = try await client.getWithLink(endpoint: Accounts.favourites(sinceId: nil)) (bookmarks, bookmarksNextPage) = try await client.getWithLink(endpoint: Accounts.bookmarks(sinceId: nil)) } reloadTabState() } catch { tabState = .statuses(statusesState: .error(error: error)) } } func fetchNextPage() async { guard let client else { return } do { switch selectedTab { case .statuses, .postsAndReplies, .media: guard let lastId = statuses.last?.id else { return } tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .loadingNextPage)) let newStatuses: [Status] = try await client.get(endpoint: Accounts.statuses(id: accountId, sinceId: lastId, tag: nil, onlyMedia: selectedTab == .media ? true : nil, excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil, pinned: nil)) statuses.append(contentsOf: newStatuses) tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)) case .favourites: guard let nextPageId = favouritesNextPage?.maxId else { return } let newFavourites: [Status] (newFavourites, favouritesNextPage) = try await client.getWithLink(endpoint: Accounts.favourites(sinceId: nextPageId)) favourites.append(contentsOf: newFavourites) tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .hasNextPage)) case .bookmarks: guard let nextPageId = bookmarksNextPage?.maxId else { return } let newBookmarks: [Status] (newBookmarks, bookmarksNextPage) = try await client.getWithLink(endpoint: Accounts.bookmarks(sinceId: nextPageId)) bookmarks.append(contentsOf: newBookmarks) tabState = .statuses(statusesState: .display(statuses: bookmarks, nextPageState: .hasNextPage)) case .followedTags, .lists: break } } catch { tabState = .statuses(statusesState: .error(error: error)) } } private func reloadTabState() { switch selectedTab { case .statuses, .postsAndReplies, .media: tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)) case .favourites: tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: favouritesNextPage != nil ? .hasNextPage : .none)) case .bookmarks: tabState = .statuses(statusesState: .display(statuses: bookmarks, nextPageState: bookmarksNextPage != nil ? .hasNextPage : .none)) case .followedTags: tabState = .followedTags case .lists: tabState = .lists } } func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) { if let event = event as? StreamEventUpdate { if event.status.account.id == currentAccount.account?.id { statuses.insert(event.status, at: 0) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } } else if let event = event as? StreamEventDelete { statuses.removeAll(where: { $0.id == event.status }) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } else if let event = event as? StreamEventStatusUpdate { if let originalIndex = statuses.firstIndex(where: { $0.id == event.status.id }) { statuses[originalIndex] = event.status statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } } } }