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/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/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/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) - } - } - } -} 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 + ) + } +}