diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index ad7b036c..bd0c1872 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -4,20 +4,25 @@ import Network import Status import Shimmer import DesignSystem +import Routeur public struct AccountDetailView: View { @Environment(\.redactionReasons) private var reasons @EnvironmentObject private var client: Client + @EnvironmentObject private var routeurPath: RouterPath + @StateObject private var viewModel: AccountDetailViewModel @State private var scrollOffset: CGFloat = 0 private let isCurrentUser: Bool + /// When coming from a URL like a mention tap in a status. public init(accountId: String) { _viewModel = StateObject(wrappedValue: .init(accountId: accountId)) isCurrentUser = false } + /// When the account is already fetched by the parent caller. public init(account: Account, isCurrentUser: Bool = false) { _viewModel = StateObject(wrappedValue: .init(account: account, isCurrentUser: isCurrentUser)) @@ -44,7 +49,12 @@ public struct AccountDetailView: View { .offset(y: -20) } - StatusesListView(fetcher: viewModel) + switch viewModel.tabState { + case .statuses: + StatusesListView(fetcher: viewModel) + case let .followedTags(tags): + makeTagsListView(tags: tags) + } } } .task { @@ -67,7 +77,7 @@ public struct AccountDetailView: View { @ViewBuilder private var headerView: some View { - switch viewModel.state { + switch viewModel.accountState { case .loading: AccountDetailHeaderView(isCurrentUser: isCurrentUser, account: .placeholder(), @@ -94,6 +104,28 @@ public struct AccountDetailView: View { Text("Error: \(error.localizedDescription)") } } + + private func makeTagsListView(tags: [Tag]) -> some View { + Group { + ForEach(tags) { tag in + HStack { + VStack(alignment: .leading) { + Text("#\(tag.name)") + .font(.headline) + Text("\(tag.totalUses) mentions from \(tag.totalAccounts) users in the last few days") + .font(.footnote) + .foregroundColor(.gray) + } + Spacer() + } + .padding(.horizontal, DS.Constants.layoutPadding) + .padding(.vertical, 8) + .onTapGesture { + routeurPath.navigate(to: .hashTag(tag: tag.name)) + } + } + } + } } struct AccountDetailView_Previews: PreviewProvider { diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index f4d84ecd..c80f7ad6 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -8,33 +8,55 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { let accountId: String var client: Client? - enum State { + enum AccountState { case loading, data(account: Account), error(error: Error) } enum Tab: Int, CaseIterable { - case statuses, favourites + case statuses, favourites, followedTags var title: String { switch self { case .statuses: return "Posts" case .favourites: return "Favourites" + case .followedTags: return "Followed Tags" } } } - @Published var state: State = .loading + enum TabState { + case followedTags(tags: [Tag]) + case statuses(statusesState: StatusesState) + } + + @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 title: String = "" @Published var relationship: Relationshionship? @Published var favourites: [Status] = [] + @Published var followedTags: [Tag] = [] @Published var selectedTab = Tab.statuses { didSet { switch selectedTab { case .statuses: - statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) + tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .hasNextPage)) case .favourites: - statusesState = .display(statuses: favourites, nextPageState: .none) + tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .none)) + case .followedTags: + tabState = .followedTags(tags: followedTags) } } } @@ -44,14 +66,16 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { private(set) var statuses: [Status] = [] private let isCurrentUser: Bool + /// When coming from a URL like a mention tap in a status. init(accountId: String) { self.accountId = accountId self.isCurrentUser = false } + /// When the account is already fetched by the parent caller. init(account: Account, isCurrentUser: Bool) { self.accountId = account.id - self.state = .data(account: account) + self.accountState = .data(account: account) self.isCurrentUser = isCurrentUser } @@ -59,31 +83,37 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { guard let client else { return } do { let account: Account = try await client.get(endpoint: Accounts.accounts(id: accountId)) - if !isCurrentUser { + if isCurrentUser { + self.followedTags = try await client.get(endpoint: Accounts.followedTags) + } else { let relationships: [Relationshionship] = try await client.get(endpoint: Accounts.relationships(id: accountId)) self.relationship = relationships.first } self.title = account.displayName - state = .data(account: account) + accountState = .data(account: account) } catch { - state = .error(error: error) + accountState = .error(error: error) } } func fetchStatuses() async { guard let client else { return } do { - statusesState = .loading + tabState = .statuses(statusesState: .loading) statuses = try await client.get(endpoint: Accounts.statuses(id: accountId, sinceId: nil)) - favourites = try await client.get(endpoint: Accounts.favourites(sinceId: nil)) + if isCurrentUser { + favourites = try await client.get(endpoint: Accounts.favourites) + } switch selectedTab { case .statuses: - statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) + tabState = .statuses(statusesState:.display(statuses: statuses, nextPageState: .hasNextPage)) case .favourites: - statusesState = .display(statuses: favourites, nextPageState: .none) + tabState = .statuses(statusesState: .display(statuses: favourites, nextPageState: .none)) + case .followedTags: + tabState = .followedTags(tags: followedTags) } } catch { - statusesState = .error(error: error) + tabState = .statuses(statusesState: .error(error: error)) } } @@ -93,15 +123,15 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { switch selectedTab { case .statuses: guard let lastId = statuses.last?.id else { return } - statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage) + tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .loadingNextPage)) let newStatuses: [Status] = try await client.get(endpoint: Accounts.statuses(id: accountId, sinceId: lastId)) statuses.append(contentsOf: newStatuses) - statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) - case .favourites: + tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .hasNextPage)) + case .favourites, .followedTags: break } } catch { - statusesState = .error(error: error) + tabState = .statuses(statusesState: .error(error: error)) } } diff --git a/Packages/Models/Sources/Models/Tag.swift b/Packages/Models/Sources/Models/Tag.swift new file mode 100644 index 00000000..a3981e54 --- /dev/null +++ b/Packages/Models/Sources/Models/Tag.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct Tag: Codable, Identifiable { + + public struct History: Codable { + public let day: String + public let accounts: String + public let uses: String + } + + public var id: String { + name + } + + public let name: String + public let url: String + public let following: Bool + public let history: [History] + + public var totalUses: Int { + history.compactMap{ Int($0.uses) }.reduce(0, +) + } + + public var totalAccounts: Int { + history.compactMap{ Int($0.accounts) }.reduce(0, +) + } +} diff --git a/Packages/Network/Sources/Network/Endpoint/Accounts.swift b/Packages/Network/Sources/Network/Endpoint/Accounts.swift index 5affb7c8..89da73e9 100644 --- a/Packages/Network/Sources/Network/Endpoint/Accounts.swift +++ b/Packages/Network/Sources/Network/Endpoint/Accounts.swift @@ -2,7 +2,9 @@ import Foundation public enum Accounts: Endpoint { case accounts(id: String) - case favourites(sinceId: String?) + case favourites + case followedTags + case featuredTags case verifyCredentials case statuses(id: String, sinceId: String?) case relationships(id: String) @@ -15,6 +17,10 @@ public enum Accounts: Endpoint { return "accounts/\(id)" case .favourites: return "favourites" + case .followedTags: + return "followed_tags" + case .featuredTags: + return "featured_tags" case .verifyCredentials: return "accounts/verify_credentials" case .statuses(let id, _): @@ -33,9 +39,6 @@ public enum Accounts: Endpoint { case .statuses(_, let sinceId): guard let sinceId else { return nil } return [.init(name: "max_id", value: sinceId)] - case .favourites(let sinceId): - guard let sinceId else { return nil } - return [.init(name: "max_id", value: sinceId)] case let .relationships(id): return [.init(name: "id", value: id)] default: