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)?