From a6ef1432d4b62131669d197897cbbb78261dd4c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 17:02:29 -0400 Subject: [PATCH] feat(currency-info): show the community leader GetCommunity already returns the currency's leader, and Community.leader parses it, but CurrencyInfoViewModel read only topHolderChats. The view model now keeps the leader from the same call, and Currency Info shows a "Community Leader" section between Top Community Chats and About. - The section shows whenever there is a leader, with or without ranked chats. A leader with no display name counts as none. - The row uses the chat row's card, now shared as CommunityRowCard, with the leader's photo, name and @username. Tapping it opens their profile with a new UserProfileOrigin.communityLeader. A leader with no user id gets no chevron and isn't tappable. - The new origin follows .deeplink except arrivesFetched: GetCommunity's profile carries no bio, cover or join date, so the profile screen still fetches. - USDF no longer calls GetCommunity. It relied on the server failing. - CommunityFetching lets the tests drive the call through MockSession. --- .../Main/Currency Info/CommunityChats.swift | 52 +++++-- .../Main/Currency Info/CommunityLeader.swift | 96 ++++++++++++ .../Currency Info/CurrencyInfoContentV2.swift | 32 ++-- .../Currency Info/CurrencyInfoScreen.swift | 1 + .../Currency Info/CurrencyInfoViewModel.swift | 16 +- .../Screens/Profile/UserProfileScreen.swift | 14 +- Flipcash/Core/Session/SessionProtocols.swift | 8 + .../CurrencyInfoViewModelTests.swift | 147 ++++++++++++++++++ FlipcashTests/SendAmountViewModelTests.swift | 1 + FlipcashTests/TestSupport/MockSession.swift | 12 ++ 10 files changed, 345 insertions(+), 34 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Currency Info/CommunityLeader.swift diff --git a/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift b/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift index b0456c898..d225d0f4b 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift @@ -53,7 +53,7 @@ struct CommunityChatRow: View { var body: some View { Button(action: onTap) { - HStack(spacing: 12) { + CommunityRowCard(title: chat.title ?? "", subtitle: subtitle, showsChevron: true) { ContactAvatarView( id: chat.id.description, displayName: chat.title ?? "", @@ -61,33 +61,53 @@ struct CommunityChatRow: View { blurhash: chat.picture?.thumbnailBlurhash, size: 42 ) - VStack(alignment: .leading, spacing: 3) { - Text(chat.title ?? "") - .font(.appTextMedium) - .foregroundStyle(Color.textMain) - .lineLimit(1) + } + } + .buttonStyle(.plain) + .task(id: chat.id) { + await sessionContainer.profileAvatars.load(.chat(chat.id), picture: chat.picture) + } + } +} + +/// The card both community rows draw: a 42pt avatar, a title over an optional subtitle, and an +/// optional chevron. Without a subtitle the title sits centred. +struct CommunityRowCard: View { + + let title: String + let subtitle: String? + let showsChevron: Bool + @ViewBuilder let avatar: () -> Avatar + + var body: some View { + HStack(spacing: 12) { + avatar() + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + if let subtitle { Text(subtitle) .font(.appTextSmall) .foregroundStyle(Color.white.opacity(0.55)) .lineLimit(1) .minimumScaleFactor(0.8) } - Spacer(minLength: 0) + } + Spacer(minLength: 0) + if showsChevron { Image(systemName: "chevron.right") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.white.opacity(0.55)) .frame(width: 16, height: 16) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .frame(height: 67) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.backgroundRow)) - .contentShape(RoundedRectangle(cornerRadius: 12)) - } - .buttonStyle(.plain) - .task(id: chat.id) { - await sessionContainer.profileAvatars.load(.chat(chat.id), picture: chat.picture) } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(height: 67) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.backgroundRow)) + .contentShape(RoundedRectangle(cornerRadius: 12)) } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CommunityLeader.swift b/Flipcash/Core/Screens/Main/Currency Info/CommunityLeader.swift new file mode 100644 index 000000000..9f9c57737 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Currency Info/CommunityLeader.swift @@ -0,0 +1,96 @@ +// +// CommunityLeader.swift +// Flipcash +// +// Community Leader on Currency Info: the Flipcash user who launched the currency, one row that +// opens their profile. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The text and visibility rules for the community leader, apart from the views so Android's +/// matching ones can be checked against them. +nonisolated enum CommunityLeader { + + static let title = "Community Leader" + + /// The leader the section shows: none when the server named nobody or the profile has no + /// display name. + static func shown(_ leader: Profile?) -> Profile? { + guard let leader, let name = leader.displayName, !name.isEmpty else { return nil } + return leader + } + + /// "@jeff", or nil when the leader has no username, which leaves the name on its own. + static func subtitle(for leader: Profile) -> String? { + leader.username?.handle + } + + /// "Community Leader, Jeff Yanta, @jeff. Opens profile.", the last sentence only when the row + /// opens the profile. + static func accessibilityLabel(for leader: Profile, isTappable: Bool) -> String { + let parts = [title, leader.displayName ?? "", subtitle(for: leader)].compactMap { $0 } + let label = parts.joined(separator: ", ") + return isTappable ? "\(label). Opens profile." : label + } +} + +/// The leader's card. Tappable, with a chevron, only when the profile carries a user id. +struct CommunityLeaderRow: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let leader: Profile + let onTap: (UserID) -> Void + + var body: some View { + Group { + if let userID = leader.userID { + Button { onTap(userID) } label: { card(showsChevron: true) } + .buttonStyle(.plain) + .accessibilityLabel(CommunityLeader.accessibilityLabel(for: leader, isTappable: true)) + } else { + card(showsChevron: false) + .accessibilityElement(children: .ignore) + .accessibilityLabel(CommunityLeader.accessibilityLabel(for: leader, isTappable: false)) + } + } + .task(id: leader.userID) { + await sessionContainer.profileAvatars.load(userID: leader.userID, picture: leader.profilePicture) + } + } + + private func card(showsChevron: Bool) -> some View { + let name = leader.displayName ?? "" + return CommunityRowCard(title: name, subtitle: CommunityLeader.subtitle(for: leader), showsChevron: showsChevron) { + ContactAvatarView( + id: leader.userID?.uuidString ?? name, + displayName: name, + imageData: leader.userID.flatMap { sessionContainer.profileAvatars.data(for: .user($0)) }, + blurhash: leader.profilePicture?.thumbnailBlurhash, + size: 42 + ) + } + } +} + +/// "Community Leader" over the leader's row. +struct CommunityLeaderSection: View { + + let leader: Profile + let onSelect: (UserID) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Community Leader") + .font(.default(size: 19, weight: .bold)) + .foregroundStyle(Color.textMain) + .frame(minHeight: 28) + .accessibilityAddTraits(.isHeader) + CommunityLeaderRow(leader: leader, onTap: onSelect) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 392e553e6..885c8b9cb 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -47,6 +47,8 @@ struct CurrencyInfoContentV2: View { let onSelectCommunityChat: (Conversation) -> Void let onShowCommunityRankingInfo: () -> Void let onShowAllCommunityChats: () -> Void + /// Opens the community leader's profile. + let onSelectCommunityLeader: (UserID) -> Void /// Opens "How Market Cap Works". let onShowMarketCapExplainer: () -> Void /// Fires when the hero card's title scrolls out from under the toolbar, so @@ -137,16 +139,26 @@ struct CurrencyInfoContentV2: View { actionTiles .padding(.horizontal, 20) - CommunityChatsSection( - chats: viewModel.communityChats, - currencyName: metadata.name, - subtitle: viewModel.communitySubtitle(for:), - onSelect: onSelectCommunityChat, - onInfo: onShowCommunityRankingInfo, - onViewAll: onShowAllCommunityChats - ) - .padding(.horizontal, 20) - .padding(.top, 24) + // Gated as a whole so a currency with neither section (USDF among them) + // keeps About where it was, rather than under an empty, padded stack. + if !viewModel.communityChats.isEmpty || viewModel.communityLeader != nil { + VStack(spacing: 24) { + CommunityChatsSection( + chats: viewModel.communityChats, + currencyName: metadata.name, + subtitle: viewModel.communitySubtitle(for:), + onSelect: onSelectCommunityChat, + onInfo: onShowCommunityRankingInfo, + onViewAll: onShowAllCommunityChats + ) + + if let leader = viewModel.communityLeader { + CommunityLeaderSection(leader: leader, onSelect: onSelectCommunityLeader) + } + } + .padding(.horizontal, 20) + .padding(.top, 24) + } CurrencyInfoAboutSection( description: currencyDescription, diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index b1ff752e6..9df08dcee 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -268,6 +268,7 @@ private struct CurrencyInfoScreenContent: View { }, onShowCommunityRankingInfo: { session.dialogItem = .info(title: CommunityChats.rankingTitle, subtitle: CommunityChats.rankingMessage(currencyName: metadata.name)) }, onShowAllCommunityChats: { router.push(.topCommunityChats(metadata.mint)) }, + onSelectCommunityLeader: { userID in router.push(.userProfile(userID, origin: .communityLeader)) }, onShowMarketCapExplainer: { router.push(.marketCapExplainer(metadata.mint)) }, onScrolledPastTitle: { scrolledPast in guard showsToolbarTitle != scrolledPast else { return } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index 99a90a80d..c59d40e96 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -62,6 +62,7 @@ class CurrencyInfoViewModel { @ObservationIgnored private let mint: PublicKey @ObservationIgnored private let session: Session + @ObservationIgnored private let communityFetcher: any CommunityFetching @ObservationIgnored private let database: Database @ObservationIgnored private let ratesController: RatesController @@ -139,9 +140,11 @@ class CurrencyInfoViewModel { /// Initializes with a mint address. Attempts a fast database lookup; /// falls back to loading state until ``loadMintMetadata()`` completes. - init(mint: PublicKey, session: Session, database: Database, ratesController: RatesController) { + /// The community loads through `communityFetcher`, or `session` when nil. + init(mint: PublicKey, session: Session, database: Database, ratesController: RatesController, communityFetcher: (any CommunityFetching)? = nil) { self.mint = mint self.session = session + self.communityFetcher = communityFetcher ?? session self.database = database self.ratesController = ratesController @@ -156,6 +159,10 @@ class CurrencyInfoViewModel { /// fails, which is how the section hides itself. private(set) var communityChats: [Conversation] = [] + /// The Flipcash user who launched the currency, from the same call as the chats. Nil until + /// loaded, when the call fails, and when the server names no leader with a display name. + private(set) var communityLeader: Profile? + /// "$10,840 of Jeffy held by 44 members", the holdings valued at the curve's current supply and /// shown in the user's display currency. func communitySubtitle(for chat: Conversation) -> String { @@ -175,11 +182,16 @@ class CurrencyInfoViewModel { ) } + /// Loads the community's chats and leader. USDF has no community, so it makes no call. func loadCommunityChats() async { + guard mint != .usdf else { return } do { - communityChats = try await session.fetchCommunity(mint: mint).topHolderChats + let community = try await communityFetcher.fetchCommunity(mint: mint) + communityChats = community.topHolderChats + communityLeader = CommunityLeader.shown(community.leader) } catch { communityChats = [] + communityLeader = nil } } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index f6fd8edcf..6f5aa0871 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -418,6 +418,8 @@ nonisolated enum UserProfileOrigin: Hashable { case transaction /// A request to join a private group the user hosts. case joinRequest + /// The leader row on a currency's info screen. + case communityLeader /// Whether Open Chat returns to the DM the profile was opened from rather than pushing a /// second copy of it. @@ -425,7 +427,7 @@ nonisolated enum UserProfileOrigin: Hashable { switch self { case .directMessage: return true - case .groupMember, .mention, .deeplink, .scan, .usernameLookup, .transaction, .joinRequest: + case .groupMember, .mention, .deeplink, .scan, .usernameLookup, .transaction, .joinRequest, .communityLeader: return false } } @@ -437,19 +439,19 @@ nonisolated enum UserProfileOrigin: Hashable { switch self { case .deeplink, .scan, .usernameLookup: return true - case .directMessage, .groupMember, .mention, .transaction, .joinRequest: + case .directMessage, .groupMember, .mention, .transaction, .joinRequest, .communityLeader: return false } } /// Whether blocking closes just the profile rather than resetting its stack. From a chat the /// stack beneath can hold the blocked person's DM, so it resets; a link opened the profile over - /// whatever the user was on, and that is where blocking returns them, as does a transaction's - /// details. A scan or a username search opens the profile as the Chats tab's only entry, so a + /// whatever the user was on, and that is where blocking returns them, as do a transaction's + /// details and a currency's leader row. A scan or a username search opens the profile as the Chats tab's only entry, so a /// reset lands on the chat list either way. var blockReturnsToOpener: Bool { switch self { - case .deeplink, .transaction, .joinRequest: + case .deeplink, .transaction, .joinRequest, .communityLeader: return true case .directMessage, .groupMember, .mention, .scan, .usernameLookup: return false @@ -462,7 +464,7 @@ nonisolated enum UserProfileOrigin: Hashable { switch self { case .scan: return .tipcard - case .directMessage, .groupMember, .mention, .deeplink, .usernameLookup, .transaction, .joinRequest: + case .directMessage, .groupMember, .mention, .deeplink, .usernameLookup, .transaction, .joinRequest, .communityLeader: return .chat } } diff --git a/Flipcash/Core/Session/SessionProtocols.swift b/Flipcash/Core/Session/SessionProtocols.swift index cc7982384..d353f51b0 100644 --- a/Flipcash/Core/Session/SessionProtocols.swift +++ b/Flipcash/Core/Session/SessionProtocols.swift @@ -37,6 +37,13 @@ protocol MintMetadataFetching: AnyObject { func fetchMintMetadata(mint: PublicKey) async throws -> StoredMintMetadata } +// MARK: - Community + +/// The community around a launched currency: its leader and ranked top-holder chats. +protocol CommunityFetching: AnyObject { + func fetchCommunity(mint: PublicKey) async throws -> Community +} + // MARK: - Buy paths /// Reserve-funded buys (existing currency or freshly launched). @@ -137,6 +144,7 @@ extension Session: AccountProviding, ProfileProviding, ProfileManaging, MintMetadataFetching, + CommunityFetching, ReservesBuying, CurrencyLaunching, RecipientResolving, diff --git a/FlipcashTests/CurrencyInfoViewModelTests.swift b/FlipcashTests/CurrencyInfoViewModelTests.swift index ae3be737c..61f970fad 100644 --- a/FlipcashTests/CurrencyInfoViewModelTests.swift +++ b/FlipcashTests/CurrencyInfoViewModelTests.swift @@ -92,3 +92,150 @@ struct CurrencyInfoViewModelTests { try await waitUntil(viewModel) { $0.mintMetadata?.supplyFromBonding == 999 } } } + +@MainActor +@Suite("CurrencyInfoViewModel community leader") +struct CurrencyInfoViewModelCommunityLeaderTests { + + private static let leaderID = UUID() + + private static func leader(displayName: String? = "Jeff Yanta", username: String? = "jeff", userID: UserID? = leaderID) -> Profile { + Profile( + displayName: displayName, + phone: Optional.none, + email: nil, + userID: userID, + username: username.flatMap(Username.init) + ) + } + + private static func chat() -> Conversation { + Conversation(id: .test(1), members: [], lastMessage: nil, lastActivity: .now, type: .group) + } + + private func makeViewModel( + mint: PublicKey = MintMetadata.makeLaunchpad().address, + fetcher: MockSession + ) -> CurrencyInfoViewModel { + let database = Database.mock + let ratesController = RatesController(container: .mock, database: database) + return CurrencyInfoViewModel( + mint: mint, + session: Session.makeMock(database: database, ratesController: ratesController), + database: database, + ratesController: ratesController, + communityFetcher: fetcher + ) + } + + @Test("A leader with a name, username, photo and user id is shown alongside the chats") + func leaderWithChats_isShown() async { + let fetcher = MockSession() + fetcher.fetchCommunityHandler = { mint in + Community(mint: mint, name: "Jeffy", leader: Self.leader(), topHolderChats: [Self.chat()]) + } + let viewModel = makeViewModel(fetcher: fetcher) + + await viewModel.loadCommunityChats() + + #expect(viewModel.communityLeader == Self.leader()) + #expect(viewModel.communityLeader?.userID == Self.leaderID) + #expect(viewModel.communityChats.count == 1) + } + + @Test("A leader is shown when there are no ranked chats") + func leaderWithoutChats_isShown() async { + let fetcher = MockSession() + fetcher.fetchCommunityHandler = { mint in + Community(mint: mint, name: "Jeffy", leader: Self.leader(), topHolderChats: []) + } + let viewModel = makeViewModel(fetcher: fetcher) + + await viewModel.loadCommunityChats() + + #expect(viewModel.communityLeader == Self.leader()) + #expect(viewModel.communityChats.isEmpty) + } + + @Test("No leader on the community shows no section") + func leaderUnset_isHidden() async { + let fetcher = MockSession() + fetcher.fetchCommunityHandler = { mint in + Community(mint: mint, name: "Jeffy", leader: nil, topHolderChats: [Self.chat()]) + } + let viewModel = makeViewModel(fetcher: fetcher) + + await viewModel.loadCommunityChats() + + #expect(viewModel.communityLeader == nil) + } + + @Test("A leader without a display name counts as no leader", arguments: [nil, ""] as [String?]) + func leaderWithoutDisplayName_isHidden(displayName: String?) async { + let fetcher = MockSession() + fetcher.fetchCommunityHandler = { mint in + Community(mint: mint, name: "Jeffy", leader: Self.leader(displayName: displayName), topHolderChats: []) + } + let viewModel = makeViewModel(fetcher: fetcher) + + await viewModel.loadCommunityChats() + + #expect(viewModel.communityLeader == nil) + } + + @Test("A failed GetCommunity clears a leader shown earlier") + func fetchFails_clearsLeader() async { + let fetcher = MockSession() + fetcher.fetchCommunityHandler = { mint in + Community(mint: mint, name: "Jeffy", leader: Self.leader(), topHolderChats: [Self.chat()]) + } + let viewModel = makeViewModel(fetcher: fetcher) + await viewModel.loadCommunityChats() + #expect(viewModel.communityLeader != nil) + + fetcher.fetchCommunityHandler = { _ in throw URLError(.notConnectedToInternet) } + await viewModel.loadCommunityChats() + + #expect(viewModel.communityLeader == nil) + #expect(viewModel.communityChats.isEmpty) + } + + @Test("USDF shows no leader and makes no GetCommunity call") + func usdf_skipsFetch() async { + let fetcher = MockSession() + var calls = 0 + fetcher.fetchCommunityHandler = { mint in + calls += 1 + return Community(mint: mint, name: "USDF", leader: Self.leader(), topHolderChats: [Self.chat()]) + } + let viewModel = makeViewModel(mint: .usdf, fetcher: fetcher) + + await viewModel.loadCommunityChats() + + #expect(calls == 0) + #expect(viewModel.communityLeader == nil) + #expect(viewModel.communityChats.isEmpty) + } +} + +@Suite("CommunityLeader row text") +struct CommunityLeaderTextTests { + + private static func leader(username: String?) -> Profile { + Profile(displayName: "Jeff Yanta", phone: Optional.none, email: nil, username: username.flatMap(Username.init)) + } + + @Test("The subtitle is the @username, or nothing without one") + func subtitle() { + #expect(CommunityLeader.subtitle(for: Self.leader(username: "jeff")) == "@jeff") + #expect(CommunityLeader.subtitle(for: Self.leader(username: nil)) == nil) + } + + @Test("The label says the row opens the profile only when it can") + func accessibilityLabel() { + let leader = Self.leader(username: "jeff") + #expect(CommunityLeader.accessibilityLabel(for: leader, isTappable: true) == "Community Leader, Jeff Yanta, @jeff. Opens profile.") + #expect(CommunityLeader.accessibilityLabel(for: leader, isTappable: false) == "Community Leader, Jeff Yanta, @jeff") + #expect(CommunityLeader.accessibilityLabel(for: Self.leader(username: nil), isTappable: false) == "Community Leader, Jeff Yanta") + } +} diff --git a/FlipcashTests/SendAmountViewModelTests.swift b/FlipcashTests/SendAmountViewModelTests.swift index 99fdbc389..210311fc2 100644 --- a/FlipcashTests/SendAmountViewModelTests.swift +++ b/FlipcashTests/SendAmountViewModelTests.swift @@ -654,6 +654,7 @@ struct SendAmountViewModelTests { (.usernameLookup, .chat), (.transaction, .chat), (.joinRequest, .chat), + (.communityLeader, .chat), ]) func profileOrigin_mapsToTipOrigin(profileOrigin: UserProfileOrigin, expected: TipOrigin) { #expect(profileOrigin.tipOrigin == expected) diff --git a/FlipcashTests/TestSupport/MockSession.swift b/FlipcashTests/TestSupport/MockSession.swift index 9ac8985f2..83703cbf1 100644 --- a/FlipcashTests/TestSupport/MockSession.swift +++ b/FlipcashTests/TestSupport/MockSession.swift @@ -18,6 +18,7 @@ final class MockSession: ProfileProviding, ProfileManaging, MintMetadataFetching, + CommunityFetching, ReservesBuying, CurrencyLaunching, RecipientResolving, @@ -60,6 +61,17 @@ final class MockSession: return try await handler(mint) } + // MARK: - Community + + var fetchCommunityHandler: (@MainActor (PublicKey) async throws -> Community)? + + func fetchCommunity(mint: PublicKey) async throws -> Community { + guard let handler = fetchCommunityHandler else { + throw MockSessionError.unimplemented(method: "fetchCommunity") + } + return try await handler(mint) + } + // MARK: - Reserves var buyHandler: (@MainActor (ExchangedFiat, ExchangedFiat?, VerifiedState, PublicKey) async throws -> SwapId)?