From 7b5bbfe314c27a7c00a2e07423d4e2c7a22014ca Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:16:59 -0400 Subject: [PATCH 1/2] refactor(profile): let the share sheet and edit capsule take their titles The group profile needs "Share Group" with a "Share on Flipcash" row, and an "Edit Group" capsule. Both default to today's strings, so the You tab and the other-user profile don't change. --- .../Core/Screens/Profile/ChatMuteRow.swift | 47 ------------------- .../Screens/Profile/ProfileHeaderView.swift | 5 +- .../Screens/Profile/ProfileShareSheet.swift | 7 ++- Flipcash/Core/Screens/Profile/ReportRow.swift | 44 ----------------- 4 files changed, 8 insertions(+), 95 deletions(-) delete mode 100644 Flipcash/Core/Screens/Profile/ChatMuteRow.swift delete mode 100644 Flipcash/Core/Screens/Profile/ReportRow.swift diff --git a/Flipcash/Core/Screens/Profile/ChatMuteRow.swift b/Flipcash/Core/Screens/Profile/ChatMuteRow.swift deleted file mode 100644 index 4aabaa791..000000000 --- a/Flipcash/Core/Screens/Profile/ChatMuteRow.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// ChatMuteRow.swift -// Flipcash -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// The mute control as both chat settings surfaces draw it: one row that opens the duration picker. -/// -/// A named unit rather than a copy per screen, because the two have to agree. A DM and a group are -/// the same mute — the contract doesn't distinguish them — so a row that wired its sheet differently -/// on one would be a bug neither screen could catch from the other. -/// -/// The row says nothing about the current mute. It reads the same muted or not, and the state it -/// would have reported lives in ``ChatMuteStatusLabel`` under the title instead. A row whose label -/// changed under the tap that changed it read as a missed tap, and the deadline it drew alongside -/// left nothing for the label — the two longest strings appeared together, because the wording that -/// grew was the muted one. -struct ChatMuteRow: View { - - let conversationID: ConversationID - let insets: EdgeInsets - - @State private var isPickingDuration = false - - var body: some View { - // No chevron: this row opens a sheet rather than pushing a screen. The spacer is the one - // the accessory would have brought, holding the label against the leading edge. - Row(insets: insets) { - Image(systemName: "bell.slash") - .frame(minWidth: 45) - Text("Mute Notifications") - .foregroundStyle(.textMain) - Spacer() - } action: { - isPickingDuration = true - } - .accessibilityIdentifier("chat-mute") - .sheet(isPresented: $isPickingDuration) { - MuteChatSheet(conversationID: conversationID, isPresented: $isPickingDuration) - } - } -} diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index cbee37178..1ea6b0331 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -128,14 +128,15 @@ struct ProfileActionCircle: View { } } -/// The header's "Edit Profile" button, a flat filled capsule beside ``ProfileActionCircle``. +/// The header's edit button ("Edit Profile" unless titled otherwise), a flat filled capsule beside ``ProfileActionCircle``. struct ProfileEditCapsule: View { + var title: String = "Edit Profile" let action: () -> Void var body: some View { Button(action: action) { - Text("Edit Profile") + Text(title) .font(.appTextSmall) .padding(.horizontal, 18) } diff --git a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift index f44bb7bbf..29df310d6 100644 --- a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift +++ b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift @@ -20,7 +20,10 @@ enum ProfileShareChoice { /// `offersCard` false drops the card row, for a profile that has no card to show. struct ProfileShareSheet: View { + var title: String = "Share User Profile" let subtitle: String? + /// The first row, which reports ``ProfileShareChoice/share``. + var shareRow: (title: String, icon: Image) = ("Share Profile", Image.asset(.shareOS)) let offersCard: Bool let onChoose: (ProfileShareChoice) -> Void @@ -43,7 +46,7 @@ struct ProfileShareSheet: View { } VStack(spacing: 12) { - row(icon: Image.asset(.shareOS), title: "Share Profile", identifier: "profile-share-row", choice: .share) + row(icon: shareRow.icon, title: shareRow.title, identifier: "profile-share-row", choice: .share) if offersCard { row(icon: Image(systemName: "person.crop.rectangle"), title: "Show Profile Card", identifier: "profile-card-row", choice: .showCard) } @@ -59,7 +62,7 @@ struct ProfileShareSheet: View { /// Centred title with a close button, the same title bar as ``MuteChatSheet``. private var header: some View { ZStack { - Text("Share User Profile") + Text(title) .font(.appBarButton) .foregroundStyle(Color.textMain) diff --git a/Flipcash/Core/Screens/Profile/ReportRow.swift b/Flipcash/Core/Screens/Profile/ReportRow.swift deleted file mode 100644 index 9f301d77f..000000000 --- a/Flipcash/Core/Screens/Profile/ReportRow.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ReportRow.swift -// Flipcash -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// The report control as both profile surfaces draw it: one row that opens the reason sheet. -/// -/// A named unit rather than a copy per screen, for the same reason ``ChatMuteRow`` is one — except -/// that here the two screens report *different things*, so the target is the parameter. A user's -/// profile names the person; a group's names the chat. Never the other way around: a tip DM's id is -/// derived on this client, so a report naming that chat would name something the server has never -/// been told about. -struct ReportRow: View { - - let target: ReportTarget - let insets: EdgeInsets - - @State private var isReporting = false - - var body: some View { - // No chevron: this row opens the reason flow as a cover rather than pushing a screen. - Row(insets: insets) { - Image(systemName: "flag") - .frame(minWidth: 45) - Text("Report") - .foregroundStyle(.textMain) - Spacer() - } action: { - isReporting = true - } - .accessibilityIdentifier("chat-report") - .fullScreenCover(isPresented: $isReporting) { - NavigationStack { - ReportFlowScreen(target: target) - } - } - } -} From 4c8503376d39d2bc4b6f32eed68565ffc6ecbfc3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:16:59 -0400 Subject: [PATCH 2/2] feat(chat): rebuild the group profile on the profile refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group profile now uses the profile header (cover, group picture, title, description), then a Chatting grid, a Balance Requirements card, and one pinned button from the gate verdict: Join, Buy $X of to Join, Buy $X of to Chat, or Open Chat. The Buy button names the shortfall; the line above it names the full requirement. The button reads the ConversationGate verdicts with membership rather than ConversationGatePresentation, which ignores membership and would ask a member who fell under the join minimum to buy in to join a group they're already in. The Chat row falls back to the Join minimum when the group sets no speaker minimum, since speaker rules equal listener rules when none are set. The Chatting grid shows only what SampleChatters returns, and is hidden for a private group (the RPC is denied there) and for an empty sample. Invite, Mute, Report and the encryption info move to the ⋯ menu; Edit becomes an "Edit Group" capsule. Leave Chat sits under the button and keeps the existing confirmation. ChatMuteRow and ReportRow had no other callers and are removed. --- .../plans/2026-10-06-group-profile-refresh.md | 31 + .../Conversation/ConversationGate.swift | 10 +- .../Screens/Profile/ChatMuteStatusLabel.swift | 4 +- .../Screens/Profile/ChatProfileScreen.swift | 544 ++++++++++++++---- .../Screens/Profile/GroupChattingGrid.swift | 95 +++ .../Screens/Profile/GroupProfileState.swift | 108 ++++ FlipcashTests/ConversationGateTests.swift | 37 +- FlipcashTests/GroupProfileStateTests.swift | 209 +++++++ .../ConversationGateReading+TestSupport.swift | 51 ++ 9 files changed, 936 insertions(+), 153 deletions(-) create mode 100644 .claude/plans/2026-10-06-group-profile-refresh.md create mode 100644 Flipcash/Core/Screens/Profile/GroupChattingGrid.swift create mode 100644 Flipcash/Core/Screens/Profile/GroupProfileState.swift create mode 100644 FlipcashTests/GroupProfileStateTests.swift create mode 100644 FlipcashTests/TestSupport/ConversationGateReading+TestSupport.swift diff --git a/.claude/plans/2026-10-06-group-profile-refresh.md b/.claude/plans/2026-10-06-group-profile-refresh.md new file mode 100644 index 000000000..08ef177de --- /dev/null +++ b/.claude/plans/2026-10-06-group-profile-refresh.md @@ -0,0 +1,31 @@ +# Group profile refresh (iOS) + +Spec: `docs/specs/group-profile-refresh.md` in the orchestrator root, sections "Approved new pieces" +and "Group profile screen". Figma file Jxf2wD9QyfKaONbhHSdU0K: node 10913:358 (profile), 10918:203 +(host view), 10935:202/10935:263 (leave), 11001:1674 (share), 10913:246/282/318 (gate states). + +## Where things live + +- `ChatProfileScreen.swift` — the screen. Same shape as `UserProfileScreen`: `ProfileHeaderView` + with `.group(conversationID, picture: coverPicture)`, ⋯ menu in the system toolbar, pinned + actions in `scrollEdgeBar(.bottom)`. +- `GroupProfileState.swift` — the pure logic, tested in `GroupProfileStateTests`: + - `GroupProfileCTA.resolve(gate:isMember:)` reads the `ConversationGate` verdicts directly. + `conversationGatePresentation` ignores membership, so it would tell a member who fell under the + join minimum to "Buy … to Join". + - `GroupBalanceRequirements` — Join is the first listener minimum; Chat is the first speaker + minimum, falling back to Join (backend: speaker rules equal listener rules when none are set). + - `balanceShortfall(of:mint:holdings:rates:)` — the Buy button names the shortfall, the line + above it the full requirement. Held balance comes from `heldBalance(in:session:)`, extracted + from `unmetBalance` in `ConversationGate.swift` so the two can't disagree. +- `GroupChattingGrid.swift` — Chatting grid and host wave badge. Fed by `sampleChatters` only, no + per-chatter GetProfile. Hidden for private groups (the RPC is DENIED) and for an empty sample. + +## Decisions + +- Join and Open Chat pop back to the conversation: the only entry is `ConversationScreen.swift:154`, + and that screen loads its transcript when the gate stops obscuring it. +- Overflow: Invite and Mute for members, Report and Encryption for everyone. Edit moved to an + "Edit Group" capsule in the header row. +- Share sheet "Share on Flipcash" opens `GroupInviteSheet`; Copy Link copies `groupChatInvite`. +- `ChatMuteRow` and `ReportRow` had no other callers and were removed. diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index 07aa4f4c6..6d98c34dd 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -232,13 +232,19 @@ private func unmetBalance( } // The contract allows at most one mint today; empty means every mint counts. let mint = requirement.mints.first - let held = mint.map { session.balance(for: $0)?.usdf ?? .usd(0) } ?? session.totalBalance.usdfValue - guard held.roundedToSmallestUnit() >= required else { + guard heldBalance(in: mint, session: session).roundedToSmallestUnit() >= required else { return .minimumBalance(amount: requirement.amount, mint: mint) } return nil } +/// What the user holds toward a balance requirement, in USD: the one mint's holding when the +/// requirement names one, else every holding together. +@MainActor +func heldBalance(in mint: PublicKey?, session: some ConversationGateReading) -> FiatAmount { + mint.map { session.balance(for: $0)?.usdf ?? .usd(0) } ?? session.totalBalance.usdfValue +} + /// Picks the requirement to name: the first unmet minimum balance, since it is /// the only one a user can act on, falling back to the first unmet rule. private func verdict(for unmet: [ConversationGateRequirement]) -> ConversationGateVerdict { diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 63dec8c83..90c84bc56 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -9,13 +9,13 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// What the chat's mute currently is, drawn under the title on both chat settings surfaces — +/// What the chat's mute currently is, drawn in the action row of both profile headers — /// "Muted until 5:56 PM" for a timed mute, plain "Muted" for an indefinite one, and nothing at all /// while the chat is audible. /// /// Stating the deadline is what makes a timed mute trustworthy: without it a timed mute can't be /// told apart from an indefinite one, and the user has no way to know when the chat comes back. -/// It sits here rather than on ``ChatMuteRow`` because the row has no room for it. +/// The Mute action itself lives in the ⋯ menu, which has no room to say it. /// /// Drawn as a chip rather than as another line of secondary text: the lines above it are the chat's /// own identity — its name, its size, when someone joined — and this is the viewer's setting. Given diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 2a401d12c..a745a7981 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -6,185 +6,501 @@ // import SwiftUI +import UIKit import FlipcashCore import FlipcashUI -/// A group chat's own profile — its picture, title, size and entry rule — reached by tapping the -/// chat's head card or its navigation title, the way a DM's title opens the counterpart's profile. +/// A group chat's own profile (node 10913:358): the shared profile header with the group's cover, +/// picture and description, who is chatting, what it takes to join and to chat, and one pinned +/// button that joins, buys in, or opens the chat. /// -/// It carries the actions a member has over a group: handing out the invite link, silencing its -/// notifications, and leaving. The head card at the top of the transcript offers the invite as well. +/// Reached by tapping the chat's head card or its navigation title, the way a DM's title opens the +/// counterpart's profile. The actions a member has over the group sit in the ⋯ menu, and Leave Chat +/// sits under the pinned button. struct ChatProfileScreen: View { let conversationID: ConversationID @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer + @Environment(RatesController.self) private var ratesController @Environment(AppRouter.self) private var router + @Environment(ToastController.self) private var toasts @State private var isInviting = false + @State private var isShowingShare = false + @State private var shareChoice: ProfileShareChoice? + @State private var isPickingMuteDuration = false + @State private var isReporting = false + @State private var isShowingE2ee = false + @State private var isJoining = false @State private var isLeaving = false @State private var dialogItem: DialogItem? + @State private var chatters: [SampledChatter] = [] + @State private var mintNames: [PublicKey: String] = [:] + + private var session: Session { sessionContainer.session } private var conversation: Conversation? { conversationController.conversation(withID: conversationID) } - /// "12 people", from ``ConversationRosterSummary/memberCount`` rather than `members.count` — a - /// large group embeds only a subset of its roster, so counting it would under-report the chat. - private var memberCount: String? { - conversation?.rosterSummary.peopleCount - } - private var title: String { conversation.map { conversationController.displayName(for: $0) } ?? "" } - /// Whether the viewer belongs to this group. Both actions are a member's: someone reading a - /// gated preview through an invite link has nothing to hand out and nothing to leave. Read from - /// the roster rather than the gate — satisfying the balance rule is not membership. + /// Whether the viewer belongs to this group. Read from the roster rather than the gate: + /// satisfying the balance rule is not membership. private var isMember: Bool { guard let conversation, conversation.type == .group else { return false } return conversationController.isMember(of: conversation) } - /// Whether to offer the overflow menu at all: it holds Edit and nothing else, so with the edit - /// permission withheld there is no menu to draw. - /// - /// Reads ``Conversation/canEdit`` — the server's answer — and nothing beside it. Membership and + /// Reads ``Conversation/canEdit``, the server's answer, and nothing beside it. Membership and /// creator identity are deliberately not consulted; see that property. private var canEdit: Bool { conversation?.canEdit ?? false } - var body: some View { - Background(color: .backgroundMain) { - VStack(spacing: 16) { - ContactAvatarView( - id: conversationID.description, - displayName: title, - imageData: sessionContainer.profileAvatars.data(for: .chat(conversationID)), - blurhash: conversation?.picture?.thumbnailBlurhash, - size: 88 - ) - .padding(.top, 40) - - // Title and size read as one block under the picture, the way the counterpart's - // profile groups a name with its handle. - VStack(spacing: 5) { - Text(title) - .font(.appDisplaySmall) - .foregroundStyle(.textMain) - .multilineTextAlignment(.center) - - if let memberCount { - Text(memberCount) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - } + private var isPrivate: Bool { + conversation?.isPrivate ?? false + } - ChatMuteStatusLabel(conversationID: conversationID) - } + // MARK: - Gate - + + /// Recomputed on each observation tick, so a balance that crosses a minimum or a rate that + /// lands moves the button without a reopen. + private var gate: ConversationGate { + guard let conversation else { return .open } + return conversationGate( + session: session, + rules: conversation.rules, + creator: conversation.creator, + rates: ratesController.cachedRates + ) + } + + private var cta: GroupProfileCTA { + guard conversation != nil else { return .none } + return GroupProfileCTA.resolve(gate: gate, isMember: isMember) + } + + private var requirements: GroupBalanceRequirements? { + GroupBalanceRequirements(conversation?.rules) + } + /// Every mint the card and the button name, deduplicated. + private var namedMints: [PublicKey] { + var mints: [PublicKey] = [] + let candidates = [requirements?.join?.mints.first, requirements?.chat?.mints.first] + for mint in candidates.compactMap({ $0 }) where !mints.contains(mint) { + mints.append(mint) + } + return mints + } + + /// "$10 of NYC", or the bare amount for a dollar-token or any-holding rule: the dollar amount + /// already states it, the way the gate panel words it. + private func holding(_ amount: FiatAmount, mint: PublicKey?) -> String { + let formatted = amount.formattedDroppingZeroFraction() + guard let mint, mint != .usdf, let name = mintNames[mint] else { return formatted } + return "\(formatted) of \(name)" + } + + // MARK: - Body - + + var body: some View { + Background(color: .backgroundMain) { + ScrollView { VStack(spacing: 0) { - if isMember { - Row(insets: rowInsets) { - Image(systemName: "person.badge.plus") - .frame(minWidth: 45) - Text("Invite People To Join") - .foregroundStyle(.textMain) - Spacer() - } action: { - Analytics.groupInviteSheetOpened( - source: .profile, - memberCount: conversation?.rosterSummary.memberCount ?? 0 + ProfileHeaderView( + cover: .group(conversationID, picture: conversation?.coverPicture), + title: title, + subtitle: nil, + bodyText: conversation?.description, + avatar: { + ProfileHeaderAvatar( + id: conversationID.description, + displayName: title, + imageData: sessionContainer.profileAvatars.data(for: .chat(conversationID)), + blurhash: conversation?.picture?.thumbnailBlurhash ) - isInviting = true - } - .accessibilityIdentifier("chat-profile-invite") + }, + bannerControls: { EmptyView() }, + rowActions: { + if isMember { + ChatMuteStatusLabel(conversationID: conversationID, reservesSpace: false) + } + if canEdit { + ProfileEditCapsule(title: "Edit Group") { + router.push(.editGroup(conversationID)) + } + .accessibilityIdentifier("chat-profile-edit") + } + shareButton + }, + underSubtitle: { EmptyView() } + ) - ChatMuteRow(conversationID: conversationID, insets: rowInsets) + if GroupChattingGrid.isVisible(isPrivate: isPrivate, chatters: chatters) { + GroupChattingGrid(chatters: chatters) { userID in + router.push(.userProfile(userID, origin: .groupMember)) + } + .padding(.top, 24) } - // Outside the membership check, unlike every other row here. A non-member has - // no link to hand out, nothing to leave, and no viewer state on a chat they are - // not in — but a group you have already left is the one you are most likely to - // report. - ReportRow(target: .chat(conversationID), insets: rowInsets) - - if isMember { - Row( - insets: rowInsets, - disabled: isLeaving, - accessory: isLeaving ? .loader(.textMain) : nil - ) { - Image(systemName: "rectangle.portrait.and.arrow.right") - .frame(minWidth: 45) - Text("Leave Group") - .foregroundStyle(.textMain) - // Only while idle: the loader accessory brings its own spacer. - if !isLeaving { - Spacer() - } - } action: { - dialogItem = leaveDialog() - } - .accessibilityIdentifier("chat-profile-leave") + if let requirements { + balanceRequirements(requirements) + .padding(.top, 24) } } - .font(.appDisplayXS) - .padding(.top, 24) - - Spacer() + .padding(.bottom, 24) } - .padding(.horizontal, 20) + // The banner runs under the status bar. + .ignoresSafeArea(edges: .top) + // The blur only belongs once the banner has scrolled up under the bar. + .hidesTopScrollEdge(untilOffset: ProfileCoverBanner.height / 2) } - .safeAreaInset(edge: .bottom) { - E2eeFooter(kind: .group) + .scrollEdgeBar(.bottom) { + pinnedActions + .toastClearance(toasts) } - .navigationTitle("") - .toolbarTitleDisplayMode(.inline) .toolbar { - if canEdit { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - router.push(.editGroup(conversationID)) - } label: { - Label { - Text("Edit") - } icon: { - Image.system(.pencil) - } - } - } label: { - Image.system(.ellipsis) - } - .accessibilityIdentifier("chat-profile-overflow") - } + ToolbarItem(placement: .topBarTrailing) { + overflowMenu } } .dialog(item: $dialogItem) + .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { + ProfileShareSheet( + title: "Share Group", + subtitle: title, + shareRow: ("Share on Flipcash", Image(systemName: "paperplane")), + offersCard: false + ) { shareChoice = $0 } + } .sheet(isPresented: $isInviting) { GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) { chatID in router.push(.tipConversation(chatID)) } } + .sheet(isPresented: $isPickingMuteDuration) { + MuteChatSheet(conversationID: conversationID, isPresented: $isPickingMuteDuration) + } + .sheet(isPresented: $isShowingE2ee) { + E2eeLearnMoreSheet(kind: .group, isPresented: $isShowingE2ee) + } + .fullScreenCover(isPresented: $isReporting) { + NavigationStack { + ReportFlowScreen(target: .chat(conversationID)) + } + } .task { await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) } + // Waits for the conversation to land before asking: a private group's sample is denied. + .task(id: conversation.map { $0.isPrivate } ) { + guard let conversation, !conversation.isPrivate else { return } + await loadChatters() + } + // Name the requirements in the token they ask for. The mint may be one the user holds + // nothing of, so the local store can miss and the fetch is what fills it. + .task(id: namedMints) { + var names: [PublicKey: String] = [:] + for mint in namedMints { + if let stored = session.storedMintMetadata(for: mint) { + names[mint] = stored.name + } else if let fetched = try? await session.fetchMintMetadata(mint: mint).name { + names[mint] = fetched + } + } + mintNames = names + } + } + + // MARK: - Chatting - + + /// Best effort: a sample that fails to load leaves the section hidden, as an empty one does. + private func loadChatters() async { + do { + chatters = try await sessionContainer.flipClient.sampleChatters( + owner: session.ownerKeyPair, + conversationID: conversationID + ).chatters + } catch { + chatters = [] + } + } + + // MARK: - Balance Requirements - + + private func balanceRequirements(_ requirements: GroupBalanceRequirements) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("Balance Requirements") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .accessibilityAddTraits(.isHeader) + + VStack(spacing: 0) { + requirementRow("Join", requirements.join, identifier: "group-profile-join-minimum") + Color.rowSeparator + .frame(height: 1) + requirementRow("Chat", requirements.chat, identifier: "group-profile-chat-minimum") + } + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + + if let yourBalance = yourBalance(in: requirements) { + HStack { + Text("Your Balance") + Spacer() + Text(yourBalance) + } + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, 16) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("group-profile-your-balance") + } + } + .padding(.horizontal, ProfileHeaderMetrics.inset) + } + + private func requirementRow(_ label: String, _ requirement: MinimumBalanceRequirement?, identifier: String) -> some View { + HStack { + Text(label) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + Spacer() + Text(requirement.map { holding($0.amount, mint: $0.mints.first) } ?? "None") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + } + .padding(.horizontal, 16) + .frame(height: 56) + .accessibilityElement(children: .combine) + .accessibilityIdentifier(identifier) } - private var rowInsets: EdgeInsets { - .init(top: 25, leading: 0, bottom: 25, trailing: 0) + /// What the viewer holds toward the rule, in the rule's currency and token: the join rule when + /// there is one, else the chat rule. Nil while no rate can restate it. + private func yourBalance(in requirements: GroupBalanceRequirements) -> String? { + guard let rule = requirements.join ?? requirements.chat else { return nil } + let mint = rule.mints.first + let held = heldBalance(in: mint, session: session).roundedToSmallestUnit() + let currency = rule.amount.currency + let stated: FiatAmount + if currency == .usd { + stated = held + } else { + guard let rate = ratesController.cachedRates[currency] else { return nil } + stated = held.converting(to: rate) + } + return holding(stated, mint: mint) } + // MARK: - Pinned - + + @ViewBuilder + private var pinnedActions: some View { + let cta = cta + if cta != .none || isMember { + VStack(spacing: 8) { + if let line = requirementLine(cta) { + Text(line) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .accessibilityIdentifier("group-profile-requirement-line") + } + if let ctaTitle = ctaTitle(cta) { + Button { tap(cta) } label: { + ButtonStateLabel(ctaTitle, state: isJoining ? .loading : .normal) + } + .buttonStyle(.filled) + .disabled(isJoining) + .accessibilityIdentifier("group-profile-cta") + } + if isMember { + Button { + dialogItem = leaveDialog() + } label: { + ButtonStateLabel("Leave Chat", state: isLeaving ? .loading : .normal) + } + .buttonStyle(.subtle) + .disabled(isLeaving) + .accessibilityIdentifier("chat-profile-leave") + } + } + .padding(.horizontal, ProfileHeaderMetrics.inset) + .padding(.top, 12) + .padding(.bottom, 8) + } + } + + /// The full requirement over a Buy button, which itself names only the shortfall. + private func requirementLine(_ cta: GroupProfileCTA) -> String? { + switch cta { + case .buyToJoin(let amount, let mint): + return "\(holding(amount, mint: mint)) Required to Join" + case .buyToChat(let amount, let mint): + return "\(holding(amount, mint: mint)) Required to Chat" + case .none, .join, .openChat: + return nil + } + } + + private func ctaTitle(_ cta: GroupProfileCTA) -> String? { + switch cta { + case .none: + return nil + case .join: + return "Join" + case .openChat: + return "Open Chat" + case .buyToJoin(let amount, let mint): + return "Buy \(holding(shortfall(of: amount, mint: mint), mint: mint)) to Join" + case .buyToChat(let amount, let mint): + return "Buy \(holding(shortfall(of: amount, mint: mint), mint: mint)) to Chat" + } + } + + /// The amount still to buy, or the whole requirement when no rate can restate the gap. + private func shortfall(of amount: FiatAmount, mint: PublicKey?) -> FiatAmount { + balanceShortfall(of: amount, mint: mint, holdings: session, rates: ratesController.cachedRates) ?? amount + } + + private func tap(_ cta: GroupProfileCTA) { + switch cta { + case .none: + break + case .join: + join() + case .openChat: + // The only way here is the chat's own head card or title, so the chat is underneath. + router.popTopmost() + case .buyToJoin(_, let mint), .buyToChat(_, let mint): + buy(mint) + } + } + + /// Buys the mint the requirement names, or opens add-cash when there is no one token to buy: a + /// rule spanning every holding, or a dollar-token one, which adding cash satisfies directly. + private func buy(_ mint: PublicKey?) { + guard let mint, mint != .usdf else { + Analytics.groupGateFundingTapped(method: .addCash, gateMint: mint) + router.presentAddMoney(.general, source: .chat) + return + } + Analytics.groupGateFundingTapped(method: .buyToken, gateMint: mint) + router.push(.buyCurrency(mint)) + } + + /// Joins, then returns to the chat underneath, which loads its transcript once the gate stops + /// obscuring it. A refused join leaves the profile as it was, so the failure is said out loud. + private func join() { + guard !isJoining else { return } + isJoining = true + // Read before the join, which seats a roster that already counts the viewer. + let memberCount = conversation?.rosterSummary.memberCount ?? 0 + let gated = conversation?.rules?.listener.isEmpty == false + Task { + defer { isJoining = false } + do { + try await conversationController.join(conversationID: conversationID) + Analytics.groupJoined(error: nil, memberCount: memberCount, gated: gated) + router.popTopmost() + } catch { + Analytics.groupJoined(error: error, memberCount: memberCount, gated: gated) + let subtitle: String + if case ErrorJoinChat.rulesNotSatisfied = error { + subtitle = "You don't meet this chat's requirements yet." + } else { + subtitle = "Something went wrong. Please try again." + } + session.dialogItem = .alert(title: "Couldn't Join Chat", subtitle: subtitle) { + DialogAction.okay(kind: .standard) + } + } + } + } + + // MARK: - Share - + + private var shareButton: some View { + ProfileActionCircle(image: Image.asset(.shareOS)) { + isShowingShare = true + } + .accessibilityLabel("Share group") + .accessibilityIdentifier("chat-profile-share") + } + + /// Runs once the share sheet is gone, so a following sheet doesn't present over it. + private func handleShareChoice() { + defer { shareChoice = nil } + switch shareChoice { + case .share: + openInvite() + case .copyLink: + UIPasteboard.general.string = URL.groupChatInvite(for: conversationID).absoluteString + toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) + case .showCard, nil: + break + } + } + + private func openInvite() { + Analytics.groupInviteSheetOpened( + source: .profile, + memberCount: conversation?.rosterSummary.memberCount ?? 0 + ) + isInviting = true + } + + // MARK: - Overflow - + + private var overflowMenu: some View { + Menu { + // Invite and Mute are a member's: someone reading a gated preview has no link to hand + // out and no viewer state to mute. + if isMember { + Button { + openInvite() + } label: { + Label("Invite People", systemImage: "person.badge.plus") + } + Button { + isPickingMuteDuration = true + } label: { + Label("Mute Notifications", systemImage: "bell.slash") + } + } + // Outside the membership check: a group you have already left is the one you are most + // likely to report. + Button { + isReporting = true + } label: { + Label("Report", systemImage: "exclamationmark.bubble") + } + Button { + isShowingE2ee = true + } label: { + Label("Encryption", systemImage: "lock") + } + } label: { + Image.system(.ellipsis) + .renderingMode(.template) + } + .accessibilityLabel("More") + .accessibilityIdentifier("chat-profile-overflow") + } + + // MARK: - Leave - + private func leaveDialog() -> DialogItem { - .alert( + .info( title: "Leave \(title)?", subtitle: "You won't receive messages from this group any more. You can join again with an invite link" ) { - DialogAction.destructive("Leave") { + DialogAction.standard("Leave Chat") { Task { await leave() } } DialogAction.cancel() @@ -207,7 +523,7 @@ struct ChatProfileScreen: View { router.popToRoot() } catch { Analytics.groupLeft(error: error, memberCount: memberCount) - sessionContainer.session.dialogItem = .error( + session.dialogItem = .error( title: "Something Went Wrong", subtitle: "We were unable to leave this group. Please try again" ) diff --git a/Flipcash/Core/Screens/Profile/GroupChattingGrid.swift b/Flipcash/Core/Screens/Profile/GroupChattingGrid.swift new file mode 100644 index 000000000..9c2a02504 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/GroupChattingGrid.swift @@ -0,0 +1,95 @@ +// +// GroupChattingGrid.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The people chatting in a public group, as `SampleChatters` returns them: portraits in four +/// columns with a name under each, the host marked with a wave. +/// +/// Draws only what the sample carries. Tapping a portrait opens that person's profile, which +/// fetches the rest. +struct GroupChattingGrid: View { + + let chatters: [SampledChatter] + let onSelect: (UserID) -> Void + + @Environment(SessionContainer.self) private var sessionContainer + + /// Whether the section is drawn: never for a private group, where the sample is denied, and not + /// for an empty sample. + nonisolated static func isVisible(isPrivate: Bool, chatters: [SampledChatter]) -> Bool { + !isPrivate && !chatters.isEmpty + } + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: 4) + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Chatting") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .accessibilityAddTraits(.isHeader) + + LazyVGrid(columns: columns, spacing: 16) { + ForEach(chatters, id: \.userID) { chatter in + portrait(chatter) + } + } + } + .padding(.horizontal, ProfileHeaderMetrics.inset) + .accessibilityIdentifier("group-profile-chatting") + } + + private func portrait(_ chatter: SampledChatter) -> some View { + let name = chatter.profile.displayName ?? chatter.profile.username?.handle ?? ConversationController.fallbackCounterpartName + return Button { + onSelect(chatter.userID) + } label: { + VStack(spacing: 6) { + ContactAvatarView( + id: chatter.userID.uuidString, + displayName: name, + imageData: sessionContainer.profileAvatars.data(for: chatter.userID), + blurhash: chatter.profile.profilePicture?.thumbnailBlurhash, + size: Self.portraitSize + ) + .overlay(alignment: .bottomTrailing) { + if chatter.isCreator { + HostBadge() + } + } + + Text(name) + .font(.appTextCaption) + .foregroundStyle(Color.textMain) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .accessibilityLabel(chatter.isCreator ? "\(name), host" : name) + .task(id: chatter.userID) { + await sessionContainer.profileAvatars.load(userID: chatter.userID, picture: chatter.profile.profilePicture) + } + } + + private static let portraitSize: CGFloat = 60 +} + +/// The wave on the host's portrait, bottom-trailing. +private struct HostBadge: View { + var body: some View { + Image(systemName: "hand.wave") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.backgroundMain) + .frame(width: 22, height: 22) + .background(Color.textMain, in: Circle()) + .overlay(Circle().stroke(Color.backgroundMain, lineWidth: 2)) + .offset(x: 2, y: 2) + .accessibilityHidden(true) + } +} diff --git a/Flipcash/Core/Screens/Profile/GroupProfileState.swift b/Flipcash/Core/Screens/Profile/GroupProfileState.swift new file mode 100644 index 000000000..0de2829c7 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/GroupProfileState.swift @@ -0,0 +1,108 @@ +// +// GroupProfileState.swift +// Flipcash +// + +import FlipcashCore + +/// The one button pinned under a group's profile, decided from the gate and whether the viewer is +/// already in the group. +nonisolated enum GroupProfileCTA: Equatable { + case none + case join + case openChat + /// Short of the join minimum `amount`, held in `mint` (nil for any holding). + case buyToJoin(amount: FiatAmount, mint: PublicKey?) + /// Short of the chat minimum `amount`, held in `mint` (nil for any holding). + case buyToChat(amount: FiatAmount, mint: PublicKey?) + + /// Reads the verdicts directly rather than ``ConversationGatePresentation``, which ignores + /// membership: a member who fell under the join minimum is asked to buy back in to chat, not + /// to join a group they are already in. + static func resolve(gate: ConversationGate, isMember: Bool) -> GroupProfileCTA { + if isMember { + switch gate.speaker { + case .satisfied: + return .openChat + case .unsatisfied(let unmet, _): + // The speaker verdict carries the unmet listener rules first and its own after, so + // the last minimum is the chat one when there is one. Buying up to it covers the + // join minimum as well. + guard let (amount, mint) = unmet.last(where: \.isMinimumBalance)?.minimumBalance else { + // Read-only, staff-only or creator-only: there is nothing to buy, and the + // transcript is still the member's to open. + return .openChat + } + return .buyToChat(amount: amount, mint: mint) + } + } + + // A rule in a currency with no rate yet passes the gate provisionally; Join would be a + // promise the real verdict might take back. + guard !gate.isProvisional else { return .none } + switch gate.listener { + case .satisfied: + return .join + case .unsatisfied(_, let primary): + guard let (amount, mint) = primary.minimumBalance else { return .none } + return .buyToJoin(amount: amount, mint: mint) + } + } +} + +nonisolated private extension ConversationGateRequirement { + var minimumBalance: (FiatAmount, PublicKey?)? { + switch self { + case .minimumBalance(let amount, let mint): return (amount, mint) + case .staff, .never, .creator, .unsupported: return nil + } + } + + var isMinimumBalance: Bool { minimumBalance != nil } +} + +/// The two rows of a group's Balance Requirements card. +nonisolated struct GroupBalanceRequirements: Equatable { + /// The listener minimum, or nil when joining needs no balance. + let join: MinimumBalanceRequirement? + /// The speaker minimum, falling back to ``join`` when the group sets none: speaker rules equal + /// listener rules when none are set. + let chat: MinimumBalanceRequirement? + + /// Nil when the group has no minimum balance rule, which hides the card. + init?(_ rules: ConversationRules?) { + let join = rules?.listener.lazy.compactMap { rule -> MinimumBalanceRequirement? in + switch rule { + case .minimumBalance(let requirement): return requirement + case .staff: return nil + } + }.first + let speaker = rules?.speaker.lazy.compactMap { rule -> MinimumBalanceRequirement? in + switch rule { + case .minimumBalance(let requirement): return requirement + case .staff, .never, .creator, .unsupported: return nil + } + }.first + guard join != nil || speaker != nil else { return nil } + self.join = join + self.chat = speaker ?? join + } +} + +/// How much more the user must hold to meet `amount`, stated in its currency and rounded up so +/// buying it is enough. Nil when the requirement is met, or when no rate can restate it. +@MainActor +func balanceShortfall( + of amount: FiatAmount, + mint: PublicKey?, + holdings: some ConversationGateReading, + rates: [CurrencyCode: Rate] +) -> FiatAmount? { + guard let required = amount.converted(to: .usd, rates: rates) else { return nil } + let held = heldBalance(in: mint, session: holdings).roundedToSmallestUnit() + guard held < required else { return nil } + let shortfall = required - held + guard amount.currency != .usd else { return shortfall } + guard let rate = rates[amount.currency] else { return nil } + return shortfall.converting(to: rate).ceiledToSmallestUnit() +} diff --git a/FlipcashTests/ConversationGateTests.swift b/FlipcashTests/ConversationGateTests.swift index 4bd91d22e..ec6ba36bd 100644 --- a/FlipcashTests/ConversationGateTests.swift +++ b/FlipcashTests/ConversationGateTests.swift @@ -13,43 +13,10 @@ import FlipcashStore @Suite("Group chat participation gate") struct ConversationGateTests { - /// Stands in for `Session`, which the gate reads three things from. - private final class StubHoldings: ConversationGateReading { - var isStaff: Bool - var userID: UserID - var totalBalance: ExchangedFiat - private var balances: [PublicKey: StoredBalance] - - init(isStaff: Bool = false, userID: UserID = UUID(), totalUSD: Decimal = 0, balances: [PublicKey: StoredBalance] = [:]) { - self.isStaff = isStaff - self.userID = userID - self.totalBalance = ExchangedFiat( - nativeAmount: .usd(totalUSD), - rate: Rate(fx: 1, currency: .usd) - ) - self.balances = balances - } + private typealias StubHoldings = StubGateHoldings - func balance(for mint: PublicKey) -> StoredBalance? { - balances[mint] - } - } - - /// A USDF holding worth `usd`, which is the one mint whose stored USD value - /// needs no bonding curve. private func holding(usd: Decimal) throws -> StoredBalance { - try StoredBalance( - quarks: NSDecimalNumber(decimal: usd * 1_000_000).uint64Value, - symbol: "USDF", - name: "USDF Coin", - supplyFromBonding: nil, - sellFeeBps: nil, - mint: .usdf, - vmAuthority: nil, - updatedAt: Date(), - imageURL: nil, - costBasis: 0 - ) + try .usdfHolding(usd: usd) } private func minimumBalance(_ usd: Decimal, mints: [PublicKey] = []) -> MinimumBalanceRequirement { diff --git a/FlipcashTests/GroupProfileStateTests.swift b/FlipcashTests/GroupProfileStateTests.swift new file mode 100644 index 000000000..cfed753c3 --- /dev/null +++ b/FlipcashTests/GroupProfileStateTests.swift @@ -0,0 +1,209 @@ +// +// GroupProfileStateTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import Flipcash +import FlipcashCore +import FlipcashStore + +@MainActor +@Suite("Group profile state") +struct GroupProfileStateTests { + + private let noRates: [CurrencyCode: Rate] = [:] + + private func minimum(_ usd: Decimal, mints: [PublicKey] = []) -> MinimumBalanceRequirement { + MinimumBalanceRequirement(amount: .usd(usd), mints: mints) + } + + private func gate( + listener: [ConversationListenerRule] = [], + speaker: [ConversationSpeakerRule] = [], + holdings: StubGateHoldings = StubGateHoldings(), + rates: [CurrencyCode: Rate] = [:] + ) -> ConversationGate { + conversationGate( + session: holdings, + rules: ConversationRules(listener: listener, speaker: speaker), + rates: rates + ) + } + + // MARK: - Pinned button + + @Test("A non-member short of the join minimum is offered the buy to join") + func nonMemberShortOfJoin() { + let gate = gate(listener: [.minimumBalance(minimum(10))]) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: false) == .buyToJoin(amount: .usd(10), mint: nil)) + } + + @Test("A non-member who meets the join minimum is offered Join") + func nonMemberEligible() { + let gate = gate(listener: [.minimumBalance(minimum(10))], holdings: StubGateHoldings(totalUSD: 12)) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: false) == .join) + } + + @Test("A non-member of a group with no rules is offered Join") + func nonMemberNoRules() { + #expect(GroupProfileCTA.resolve(gate: .open, isMember: false) == .join) + } + + @Test("A non-member held out by a rule they can't buy past gets no button") + func nonMemberStaffOnly() { + let gate = gate(listener: [.staff]) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: false) == .none) + } + + @Test("A non-member waits for a rate before the button commits to Join") + func nonMemberProvisional() { + let rule = MinimumBalanceRequirement(amount: FiatAmount(value: 10, currency: .cad)) + let gate = gate(listener: [.minimumBalance(rule)], rates: noRates) + + #expect(gate.isProvisional) + #expect(GroupProfileCTA.resolve(gate: gate, isMember: false) == .none) + } + + @Test("A member who can speak is offered Open Chat") + func memberCanSpeak() { + let gate = gate(speaker: [.minimumBalance(minimum(100))], holdings: StubGateHoldings(totalUSD: 100)) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: true) == .openChat) + } + + @Test("A member short of the chat minimum is offered the buy to chat") + func memberShortOfChat() { + let gate = gate( + listener: [.minimumBalance(minimum(10))], + speaker: [.minimumBalance(minimum(100))], + holdings: StubGateHoldings(totalUSD: 12) + ) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: true) == .buyToChat(amount: .usd(100), mint: nil)) + } + + @Test("A member short of both minimums is asked for the chat one, which covers the join one") + func memberShortOfBoth() { + let gate = gate( + listener: [.minimumBalance(minimum(10))], + speaker: [.minimumBalance(minimum(100))] + ) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: true) == .buyToChat(amount: .usd(100), mint: nil)) + } + + @Test("A member who fell under the join minimum of a group with no chat minimum buys up to the join one") + func memberUnderJoinOnly() { + let gate = gate(listener: [.minimumBalance(minimum(10))]) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: true) == .buyToChat(amount: .usd(10), mint: nil)) + } + + @Test("A member of a read-only group still gets Open Chat") + func memberReadOnly() { + let gate = gate(speaker: [.never]) + + #expect(GroupProfileCTA.resolve(gate: gate, isMember: true) == .openChat) + } + + // MARK: - Balance requirements card + + @Test("Join is the listener minimum and Chat the speaker minimum") + func requirementsBoth() { + let rules = ConversationRules( + listener: [.minimumBalance(minimum(10))], + speaker: [.minimumBalance(minimum(100))] + ) + + let requirements = GroupBalanceRequirements(rules) + #expect(requirements?.join == minimum(10)) + #expect(requirements?.chat == minimum(100)) + } + + @Test("Chat falls back to the join minimum when the group sets no chat minimum") + func requirementsChatFallsBackToJoin() { + let rules = ConversationRules(listener: [.minimumBalance(minimum(10))], speaker: [.staff]) + + let requirements = GroupBalanceRequirements(rules) + #expect(requirements?.join == minimum(10)) + #expect(requirements?.chat == minimum(10)) + } + + @Test("A chat minimum with no join minimum leaves Join empty") + func requirementsChatOnly() { + let rules = ConversationRules(speaker: [.minimumBalance(minimum(100))]) + + let requirements = GroupBalanceRequirements(rules) + #expect(requirements?.join == nil) + #expect(requirements?.chat == minimum(100)) + } + + @Test("The card is hidden for a group with no minimum balance rule", arguments: [ + nil, + ConversationRules(), + ConversationRules(listener: [.staff], speaker: [.never]), + ]) + func requirementsHidden(rules: ConversationRules?) { + #expect(GroupBalanceRequirements(rules) == nil) + } + + // MARK: - Shortfall + + @Test("The shortfall is the requirement less what is held") + func shortfallAcrossHoldings() { + let holdings = StubGateHoldings(totalUSD: Decimal(string: "6.33")!) + + let shortfall = balanceShortfall(of: .usd(10), mint: nil, holdings: holdings, rates: noRates) + #expect(shortfall == .usd(Decimal(string: "3.67")!)) + } + + @Test("A requirement on one mint counts only that mint's holding") + func shortfallOneMint() throws { + let holdings = StubGateHoldings(totalUSD: 50, balances: [.usdf: try .usdfHolding(usd: 4)]) + + let shortfall = balanceShortfall(of: .usd(10), mint: .usdf, holdings: holdings, rates: noRates) + #expect(shortfall == .usd(6)) + } + + @Test("A requirement in another currency states its shortfall in that currency") + func shortfallRestated() { + let rates: [CurrencyCode: Rate] = [.cad: Rate(fx: Decimal(string: "1.4")!, currency: .cad)] + let holdings = StubGateHoldings(totalUSD: 5) + + let shortfall = balanceShortfall(of: FiatAmount(value: 14, currency: .cad), mint: nil, holdings: holdings, rates: rates) + #expect(shortfall == FiatAmount(value: 7, currency: .cad)) + } + + @Test("There is no shortfall once the requirement is met") + func shortfallMet() { + let holdings = StubGateHoldings(totalUSD: 10) + + #expect(balanceShortfall(of: .usd(10), mint: nil, holdings: holdings, rates: noRates) == nil) + } + + // MARK: - Chatting grid + + private func chatter() -> SampledChatter { + SampledChatter(userID: UUID(), profile: Profile(displayName: "Fred", phone: Phone?.none, email: nil), lastSentAt: nil, isCreator: true) + } + + @Test("A private group never shows the grid, whatever was sampled") + func gridPrivate() { + #expect(!GroupChattingGrid.isVisible(isPrivate: true, chatters: [chatter()])) + } + + @Test("An empty sample hides the grid") + func gridEmpty() { + #expect(!GroupChattingGrid.isVisible(isPrivate: false, chatters: [])) + } + + @Test("A public group with a sample shows the grid") + func gridShown() { + #expect(GroupChattingGrid.isVisible(isPrivate: false, chatters: [chatter()])) + } +} diff --git a/FlipcashTests/TestSupport/ConversationGateReading+TestSupport.swift b/FlipcashTests/TestSupport/ConversationGateReading+TestSupport.swift new file mode 100644 index 000000000..2316649c4 --- /dev/null +++ b/FlipcashTests/TestSupport/ConversationGateReading+TestSupport.swift @@ -0,0 +1,51 @@ +// +// ConversationGateReading+TestSupport.swift +// FlipcashTests +// + +import Foundation +@testable import Flipcash +import FlipcashCore +import FlipcashStore + +/// Stands in for `Session`, which the gate reads three things from. +final class StubGateHoldings: ConversationGateReading { + var isStaff: Bool + var userID: UserID + var totalBalance: ExchangedFiat + private var balances: [PublicKey: StoredBalance] + + init(isStaff: Bool = false, userID: UserID = UUID(), totalUSD: Decimal = 0, balances: [PublicKey: StoredBalance] = [:]) { + self.isStaff = isStaff + self.userID = userID + self.totalBalance = ExchangedFiat( + nativeAmount: .usd(totalUSD), + rate: Rate(fx: 1, currency: .usd) + ) + self.balances = balances + } + + func balance(for mint: PublicKey) -> StoredBalance? { + balances[mint] + } +} + +extension StoredBalance { + + /// A USDF holding worth `usd`, which is the one mint whose stored USD value + /// needs no bonding curve. + static func usdfHolding(usd: Decimal) throws -> StoredBalance { + try StoredBalance( + quarks: NSDecimalNumber(decimal: usd * 1_000_000).uint64Value, + symbol: "USDF", + name: "USDF Coin", + supplyFromBonding: nil, + sellFeeBps: nil, + mint: .usdf, + vmAuthority: nil, + updatedAt: Date(), + imageURL: nil, + costBasis: 0 + ) + } +}