From f9dfdee7c9f4268a3662de1bd20d3fb757b14f5c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 19:29:30 -0400 Subject: [PATCH 01/14] feat(chat): create and join a public group chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group chat had no way in and no way to make one. This adds both, plus the push tap that has to land in the same place as an invite link. Creating (node 10153:22901). "New Chat" replaces the old sheet with a pushed screen offering "Create a Public Group" and "Find by Username". The group form takes a title, an optional picture, and a minimum-balance rule over one mint. Three things the contract requires: - The idempotency key is minted when the user taps Create, not at the call site, and held for every retry of that attempt. The server derives chat identity from caller plus key, so a retry returns the chat the first attempt created; a key minted inside the call would make each retry a new chat. - The creator has to satisfy the rule they set, or StartChat answers RULES_NOT_SATISFIED. `NewPublicGroupModel` checks the selected mint's balance before enabling Create and says why when it doesn't clear the bar. - The picture uploads first and is held across retries, since StartChat only accepts a blob the caller already owns. The form opens on a mint rather than on a blank: node 10127:118014 draws the currency row naming a token while everything else is still empty. It seats the wallet's top card, which `StoredBalance.walletOrder` puts at the largest holding, so the creator starts on a rule they can satisfy — the same mint Android's `BalanceOrder` picks. That makes "a mint is set" stop meaning "the user chose one", which the requirement's heading turns on: node 10127:118014 reads "Minimum Balance Required" over a named mint, and node 10127:118194 reads "Balance Requirement" over a picked one with no amount collected yet. Hence `hasChosenMint` alongside the mint itself. All six StartChatResponse.Result values are handled. TITLE_MODERATED reuses the existing flagged-category mapping, and a refused picture reuses the profile picture dialog, which fails the same ways and reads the same to the user. Joining (node 10153:22902). There is no invite RPC, so the link is built from the chat's own id: `https://app.flipcash.com/chat/`, matching what Android builds. `common.v1.ChatId.value` is a 16-byte UUID for groups, so the id round-trips through the link. `Route` now accepts the dashed form on `/chat/{id}` alongside the base64url one, and `/chat/{id}/send` keeps its meaning. `app.flipcash.com` is already in the associated-domains entitlement and the live AASA covers `/chat/*`. A group the viewer hasn't joined opens the transcript in its gated state rather than a separate screen, so a push tap and an invite link share one destination. The server result is authoritative: RULES_NOT_SATISFIED on JoinChat re-renders the gate rather than trusting the local balance check. The blurred content behind the gate is a placeholder. The contract has no non-member message read, so `GatePreviewPlaceholder` draws static rows under the blur instead of fetching anything. The group's profile carries the two membership actions. The head card's "Invite People To Join" disappears once a second person joins, so the profile row is the only way to the link after that; both entry points open the same sheet, which builds one URL from the chat id. Leaving sits below it behind a destructive confirmation, and NOT_FOUND from LeaveChat clears membership rather than throwing, since the server is saying it holds no membership to remove and the local flag is the stale one. Push. A CHAT payload carries `navigation.chatID`, so the tap routes on that id through `URL.chatDeepLink` rather than on `target_url`, which stays the fallback for every other push. `DeepLinkAction.chatDestination` is the one place that decides where a chat id goes. `ChatService.startChat` now logs the gRPC code when the call fails at the transport, which is the one failure whose cause isn't in the result. --- .../Controllers/ConversationController.swift | 23 +- .../Deep Links/DeepLinkController.swift | 21 +- .../Core/Controllers/Deep Links/Route.swift | 7 +- .../Core/Controllers/PushController.swift | 26 +- .../Navigation/AppRouter+Destination.swift | 10 +- .../AppRouter+DestinationView.swift | 6 + .../ChatScreenRepresentable.swift | 10 + .../Conversation/ConversationScreen.swift | 26 +- .../Profile/DialogItem+ProfilePicture.swift | 3 +- .../Screens/Main/SelectCurrencyScreen.swift | 12 +- .../Screens/Main/Tips/ChatActionRow.swift | 45 ++ .../Tips/New Group/GroupChatCreating.swift | 66 +++ .../Tips/New Group/GroupInviteSheet.swift | 77 ++++ .../New Group/MinimumBalanceAmountSheet.swift | 87 ++++ .../Tips/New Group/NewPublicGroupModel.swift | 197 ++++++++ .../Tips/New Group/NewPublicGroupScreen.swift | 429 ++++++++++++++++++ .../Screens/Main/Tips/NewChatScreen.swift | 42 ++ .../Main/Tips/TipConversationsScreen.swift | 2 +- .../Screens/Profile/ChatProfileScreen.swift | 86 +++- Flipcash/Utilities/URL+Links.swift | 21 + .../Flip API/Services/ChatService.swift | 3 + .../Models/Chat/ChatGroupCard.swift | 8 +- .../ConversationIdentifiers.swift | 22 + .../Validation/GroupTitleValidator.swift | 35 ++ .../Chat/ConversationMembershipTests.swift | 17 + .../Chat/GroupPushRoutingTests.swift | 154 +++++++ .../Chat/NewPublicGroupModelTests.swift | 321 +++++++++++++ FlipcashTests/RouteTests.swift | 42 ++ .../icons/IconAt.imageset/Contents.json | 16 + .../icons/IconAt.imageset/IconAt.svg | 5 + .../icons/IconGroup3.imageset/Contents.json | 16 + .../icons/IconGroup3.imageset/IconGroup3.svg | 8 + .../FlipcashUI/Chat/ChatGroupCardCell.swift | 41 +- .../Chat/ChatScreenViewController.swift | 17 + .../FlipcashUI/Chat/ChatViewController.swift | 7 +- .../Chat/GatePreviewPlaceholder.swift | 100 ++++ .../FlipcashUI/Chat/TranscriptBlur.swift | 3 + .../FlipcashUI/Theme/Image+Symbols.swift | 7 + 38 files changed, 1990 insertions(+), 28 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/New Group/GroupChatCreating.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Validation/GroupTitleValidator.swift create mode 100644 FlipcashTests/Chat/GroupPushRoutingTests.swift create mode 100644 FlipcashTests/Chat/NewPublicGroupModelTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/Contents.json create mode 100644 FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/IconGroup3.svg create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index f669141f6..849abb668 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -628,10 +628,31 @@ final class ConversationController { refreshFeedPreview(for: conversationID) } + /// Seats a group the signed-in user just created, so its screen and the Chats list have it + /// without waiting for the next feed fetch. + /// + /// Separate from ``join(conversationID:)`` because there is nothing to join: `StartChat` returns + /// the creator already seated, and the metadata it returns is the same shape `joinChat` gives + /// back, so everything after the RPC is identical. + func seatCreatedGroup(_ conversation: Conversation) { + store.apply(.metadataRefresh(conversation)) + store.setMembership(true, in: conversation.id) + persistConversation(conversation) + persistMembership(true, in: conversation.id) + refreshFeedPreview(for: conversation.id) + } + /// Leaves a group. The chat stays in the store — the screen the user left from is still on top and /// needs it to render the gate — but drops out of ``joinedGroups``, so it leaves the Chats list. + /// + /// `notFound` counts as having left. It means the server holds no membership to remove, so the + /// local flag is the stale one and clearing it is what reconciles the two; throwing would leave + /// the user in a chat the server says they are not in. func leave(conversationID: ConversationID) async throws { - try await membership.leaveChat(owner: owner, conversationID: conversationID) + do { + try await membership.leaveChat(owner: owner, conversationID: conversationID) + } catch ErrorLeaveChat.notFound { + } store.setMembership(false, in: conversationID) persistMembership(false, in: conversationID) } diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index cd7fed1c1..d3e979faf 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -186,13 +186,26 @@ struct DeepLinkAction { private static func routeChat(_ conversationID: ConversationID, in container: SessionContainer) async { let conversation = await container.conversationController.hydratedConversation(withID: conversationID) - switch conversation?.type { - case .tipDm, .group: - container.appRouter.navigate(to: .tipConversation(conversationID)) - case .contactDm, nil: + guard let destination = chatDestination(for: conversation?.type, conversationID: conversationID) else { logger.info("Ignoring non-tip chat deeplink", metadata: [ "conversationID": "\(conversationID)", ]) + return + } + + container.appRouter.navigate(to: destination) + } + + /// The screen a chat id opens, or nil when it isn't one the app surfaces. + /// + /// A group routes to the same transcript a tip DM does whether or not the viewer is a member: + /// the screen gates itself, so an invite link and a push tap land on one destination. + static func chatDestination(for type: ConversationType?, conversationID: ConversationID) -> AppRouter.Destination? { + switch type { + case .tipDm, .group: + return .tipConversation(conversationID) + case .contactDm, nil: + return nil } } diff --git a/Flipcash/Core/Controllers/Deep Links/Route.swift b/Flipcash/Core/Controllers/Deep Links/Route.swift index 0d259b042..fbc47cb48 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -160,7 +160,12 @@ nonisolated extension Route { guard components.count > 1 else { return nil } - guard let id = ConversationID(base64URLEncoded: components[1]) else { + // A group's id is a 16-byte UUID and travels as the dashed form + // an invite link carries; a DM's is a 32-byte hash with no UUID + // form, so it stays base64url. Both are in circulation. + guard let id = ConversationID(uuidString: components[1]) + ?? ConversationID(base64URLEncoded: components[1]) + else { return nil } // `/chat/{id}/send` opens the Send Cash sheet over the chat. diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 77b79af66..b0b4ac970 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -318,13 +318,21 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification postContactJoinIfNeeded(response.notification.request.content.userInfo) - if response.actionIdentifier == ChatNotificationCategory.sendCashActionID, - let conversationID = NotificationPayload.chatID(response.notification.request.content.userInfo) - { - let url = URL(string: "flipcash://chat/\(conversationID.base64URLEncoded)/send")! - handleTargetUrlIfNeeded(url.absoluteString) + let userInfo = response.notification.request.content.userInfo + let conversationID = NotificationPayload.chatID(userInfo) + + if let conversationID { + // The push carries the chat it is about, so the tap routes on that + // rather than on `target_url`: the same id an invite link carries, + // through the same route, so a group the user hasn't joined lands on + // the gated screen either way. `target_url` stays the fallback for + // every non-chat push. + handleDeepLink(URL.chatDeepLink( + for: conversationID, + sendCash: response.actionIdentifier == ChatNotificationCategory.sendCashActionID + )) } else { - let aps = response.notification.request.content.userInfo["aps"] as? [String: Any] + let aps = userInfo["aps"] as? [String: Any] handleTargetUrlIfNeeded(aps?["target_url"] as? String) } @@ -346,7 +354,11 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification return } - logger.debug("Forwarding notification target_url to deep link handler", metadata: ["url": "\(url.sanitizedForAnalytics)"]) + handleDeepLink(url) + } + + private func handleDeepLink(_ url: URL) { + logger.debug("Forwarding notification deep link to handler", metadata: ["url": "\(url.sanitizedForAnalytics)"]) Task { @MainActor in NotificationCenter.default.post( diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 3b86cb68c..0196b0d4f 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -88,6 +88,10 @@ extension AppRouter { /// Finding someone by their Flipcash handle, opened from the Chats tab. /// Distinct from `.username`, which claims the signed-in user's own. case usernameLookup + /// The list of ways to start a chat, pushed from the Chats tab's `+`. + case newChat + /// The "New Public Group" form, opened from the New Chat screen. + case newPublicGroup /// A tip DM conversation, pushed onto the `.tips` stack — from the /// Tips list or a tip-DM push notification. case tipConversation(ConversationID) @@ -126,7 +130,7 @@ extension AppRouter { .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw: return .you - case .profileName, .profilePhoto, .tipcard, .usernameLookup, + case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .tipConversation, .tipConversationWithKeyboard, .tipConversationForUser, .userProfile, .chatProfile: return .tips @@ -171,6 +175,8 @@ extension AppRouter { case .profilePhoto: "profilePhoto" case .tipcard: "tipcard" case .usernameLookup: "usernameLookup" + case .newChat: "newChat" + case .newPublicGroup: "newPublicGroup" case .tipConversation: "tipConversation" case .tipConversationWithKeyboard: "tipConversationWithKeyboard" case .tipConversationForUser: "tipConversationForUser" @@ -213,7 +219,7 @@ extension AppRouter { .settingsAdvancedFeatures, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw, - .profileName, .profilePhoto, .tipcard, .usernameLookup: + .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup: return nil } } diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 8a975be71..94bc87726 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -166,6 +166,12 @@ struct DestinationView: View { case .usernameLookup: UsernameLookupScreen() + case .newChat: + NewChatScreen() + + case .newPublicGroup: + NewPublicGroupScreen() + case .tipConversation(let conversationID): // `.id` forces fresh view identity per conversation. ConversationScreen(context: .existing(conversationID)) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 7fa0e1cbe..eb34e83f8 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -38,6 +38,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Fired when the user taps the transcript's head card — the counterpart's in a tip DM, the /// chat's own in a group; nil disables the card tap. let onProfileTap: (() -> Void)? + /// Fired when the user taps the group head card's "Invite People To Join". The card draws the + /// button only when it both asks for it and this is set. + let onGroupInvite: (() -> Void)? /// Fired when the user taps an author's face in a group's gutter, with that author's user id. let onAuthorTap: (UserID) -> Void /// Fired when a context-menu action is chosen on a row, with the row's stable id. Copy never @@ -69,6 +72,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// may read at all. Drives the gate panel in place of the bar and the blur over the transcript. let gate: ConversationGatePresentation /// Ticker for the mint the gate's requirement names, once resolved. + /// Whether the gate's decorative shapes are drawn behind the blur — see + /// ``GatePreviewPlaceholder``. Set for a chat the viewer cannot read and has no history of. + let showsGatePlaceholder: Bool let gateSymbol: String? /// Opens the buy or add-cash flow from the gate panel's CTA. let onGateAddFunds: () -> Void @@ -89,6 +95,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let screen = ChatScreenViewController(bar: barHost.view, barController: barHost) screen.focusesComposerOnAppear = focusOnAppear screen.isTranscriptObscured = gate.obscuresTranscript + screen.showsGatePlaceholder = showsGatePlaceholder screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry @@ -97,6 +104,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onLinkCardTap = onLinkCardTap screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap + screen.onGroupInvite = onGroupInvite screen.onAuthorTap = onAuthorTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) screen.onQuoteTap = { [weak screen] stableID in @@ -118,6 +126,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { // focus survive across updates. context.coordinator.barHost?.rootView = bar(coordinator: context.coordinator) screen.isTranscriptObscured = gate.obscuresTranscript + screen.showsGatePlaceholder = showsGatePlaceholder screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry @@ -126,6 +135,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onLinkCardTap = onLinkCardTap screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap + screen.onGroupInvite = onGroupInvite screen.onAuthorTap = onAuthorTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) screen.onQuoteTap = { [weak screen] stableID in diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 3d58e7e78..671fc224c 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -76,6 +76,9 @@ struct ConversationScreen: View { @State private var isJoiningChat = false /// Memoizes the head-carded transcript — see ``TranscriptHead``. @State private var transcriptHead = TranscriptHead() + /// Whether the invite sheet is up. A link is the only way into a group, so the empty group's + /// card hands one out (node 10127:118280). + @State private var isInviting = false /// Horizontal space the back button (leading) reserves on each side of the /// centered title item, so the avatar + name can left-align inside a @@ -328,10 +331,22 @@ struct ConversationScreen: View { avatarID: group.id.description, imageData: groupAvatarSubject.flatMap { sessionContainer.profileAvatars.data(for: $0) }, blurhash: group.picture?.thumbnailBlurhash, - requirement: groupCardRequirement + requirement: groupCardRequirement, + showsInvite: showsGroupInvite ) } + /// Whether the head card offers the invite link: a group the viewer belongs to that nobody else + /// has joined. Once someone else is in, the invite lives on the chat's profile instead, so it + /// stops competing with the transcript (node 10127:118280). + /// + /// Counted from ``ConversationRosterSummary/memberCount`` for the reason ``titleSubtitle`` is — + /// an embedded roster is a subset. + private var showsGroupInvite: Bool { + guard let group = groupConversation else { return false } + return conversationController.isMember(of: group) && group.rosterSummary.memberCount <= 1 + } + /// The chat's entry rule as the card states it (node 10125:19164), or nil when the chat states /// none. It states the rule whether or not the viewer satisfies it, so it reads the chat's /// advertised listener rule rather than anything the gate found unmet. The line is broken after @@ -415,6 +430,7 @@ struct ConversationScreen: View { onLinkCardTap: openLinkCard, onContactAction: openContactCard, onProfileTap: profileTapAction, + onGroupInvite: { isInviting = true }, onAuthorTap: openAuthorProfile, onMessageAction: handleMessageAction, onQuoteTap: jumpToQuote, @@ -431,6 +447,9 @@ struct ConversationScreen: View { isTipDm: tipCounterpart != nil, startChattingFee: startChattingFee, gate: gate, + // The contract has no non-member read, so a gated chat the viewer has no history of has + // nothing under its blur. The shapes stand in for what they are not allowed to see. + showsGatePlaceholder: gate.obscuresTranscript && (coordinator?.items.isEmpty ?? true), gateSymbol: gateSymbol, onGateAddFunds: addFunds, onGateJoin: joinChat, @@ -545,6 +564,11 @@ struct ConversationScreen: View { .sheet(item: $startChattingRequest) { request in StartChattingSheet(target: request.target, fee: request.fee) } + .sheet(isPresented: $isInviting) { + if let conversationID { + GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) + } + } .background { // Measure the bar width so the centered title item can be sized to // (almost) fill it — the system toolbar won't honor maxWidth on a diff --git a/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift b/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift index f30e4d8a1..a63d3c362 100644 --- a/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift +++ b/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift @@ -16,7 +16,8 @@ extension DialogItem { ) } - /// Returns the dialog explaining why a profile picture didn't upload. + /// Returns the dialog explaining why an uploaded image was refused — the profile picture and + /// a group chat's, which fail the same ways and read the same to the user. static func profilePictureFailed(_ error: ErrorBlob) -> DialogItem { switch error { case .rejected(.moderation): diff --git a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift index d2a838cc9..3b9f84404 100644 --- a/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift +++ b/Flipcash/Core/Screens/Main/SelectCurrencyScreen.swift @@ -18,19 +18,26 @@ struct SelectCurrencyScreen: View { let action: (ExchangedBalance) -> Void let isEnabled: (ExchangedBalance) -> Bool + let isSelected: ((ExchangedBalance) -> Bool)? private var balances: [ExchangedBalance] { session.balances(for: ratesController.rateForBalanceCurrency()) .giveable() } + /// - Parameter isSelected: which row draws its checkmark. Defaults to the currency the wallet is + /// denominated in, which is what picking one here changes. Pass a closure when the screen is + /// picking for something else — a group chat's balance requirement, say — so the check follows + /// that choice instead of the wallet's. init( isPresented: Binding, isEnabled: @escaping (ExchangedBalance) -> Bool = { _ in true }, + isSelected: ((ExchangedBalance) -> Bool)? = nil, action: @escaping (ExchangedBalance) -> Void ) { self._isPresented = isPresented self.isEnabled = isEnabled + self.isSelected = isSelected self.action = action } @@ -56,7 +63,10 @@ struct SelectCurrencyScreen: View { let enabled = isEnabled(balance) CurrencyBalanceRow( exchangedBalance: balance, - accessory: .check(isSelected: ratesController.isSelectedToken(balance.stored.mint)), + accessory: .check( + isSelected: isSelected?(balance) + ?? ratesController.isSelectedToken(balance.stored.mint) + ), amountStyle: .pill ) { action(balance) diff --git a/Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift b/Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift new file mode 100644 index 000000000..a20f38517 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/ChatActionRow.swift @@ -0,0 +1,45 @@ +// +// ChatActionRow.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// One choice on a screen that is nothing but choices — a glyph and a label on a filled card +/// (nodes 10127:118000 and 10127:118327). +/// +/// The chats flow has two of these screens and they are drawn identically, so the row is shared +/// rather than written twice. +struct ChatActionRow: View { + + let icon: Image + let title: String + let accessibilityIdentifier: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + icon + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(Color.textMain) + + Text(title) + .font(.default(size: 17, weight: .bold)) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.leading) + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + .contentShape(.rect(cornerRadius: Metrics.buttonRadius)) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/GroupChatCreating.swift b/Flipcash/Core/Screens/Main/Tips/New Group/GroupChatCreating.swift new file mode 100644 index 000000000..aabeb5f4f --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/GroupChatCreating.swift @@ -0,0 +1,66 @@ +// +// GroupChatCreating.swift +// Flipcash +// + +import Foundation +import FlipcashCore + +/// The remote half of creating a group chat, in the order the flow calls it. +/// +/// The owner is bound by the conformer, so the flow never handles keys — the seam +/// ``ProfilePictureUploading`` draws for the profile photo, for the same reason. The picture is +/// here rather than in a protocol of its own because the server requires it finalized *before* +/// `StartChat` will accept it, so the two calls are one ordered sequence rather than two the +/// screen would have to interleave. +protocol GroupChatCreating { + + /// Stores `data` and returns its blob, before the server has finalized it. + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID + + /// Returns once the blob is servable, throwing `ErrorBlob.rejected` when it is refused and + /// `ErrorBlob.timedOut` when it is still processing. + func awaitBlobFinalization(blobID: BlobID) async throws + + /// Creates the group and returns its metadata as the server minted it. + /// + /// `idempotencyKey` identifies the *attempt*, not the parameters: the server derives the + /// chat's identity from the caller and the key, so a retry carrying the key an earlier call + /// already used returns the chat that call created — with `OK`, and with the title it sent. + func startChat( + title: String, + pictureBlobID: BlobID?, + rules: ConversationRules?, + idempotencyKey: UUID + ) async throws -> Conversation +} + +/// Creates on behalf of the signed-in owner. +struct SessionGroupChatCreator: GroupChatCreating { + + let session: Session + let flipClient: FlipClient + + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { + try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair) + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) + } + + func startChat( + title: String, + pictureBlobID: BlobID?, + rules: ConversationRules?, + idempotencyKey: UUID + ) async throws -> Conversation { + try await flipClient.startChat( + owner: session.ownerKeyPair, + title: title, + pictureBlobID: pictureBlobID, + rules: rules, + idempotencyKey: idempotencyKey + ) + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift new file mode 100644 index 000000000..99ef57bdd --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift @@ -0,0 +1,77 @@ +// +// GroupInviteSheet.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashUI + +/// The two ways to hand out a group's invite link (node 10127:118315). +/// +/// A group is not discoverable — the link built from its id is the only way in — so this is the +/// whole of its invite story, and both rows share one URL rather than each building its own. +struct GroupInviteSheet: View { + + let conversationID: ConversationID + + @Binding var isPresented: Bool + + /// How long the copy row stays on its checkmark before reverting — the beat the tip-card link + /// row holds. + private static let confirmationDuration: Duration = .seconds(1.5) + + @State private var didCopy = false + + private var url: URL { + .groupChatInvite(for: conversationID) + } + + var body: some View { + NavigationStack { + Background(color: .backgroundMain) { + VStack(spacing: 12) { + ChatActionRow( + icon: .asset(.at), + title: "Send Invite Link", + accessibilityIdentifier: "group-invite-send" + ) { + ShareSheet.present(activityItems: [url]) { _ in } + } + + ChatActionRow( + icon: didCopy ? .system(.circleCheck) : .asset(.squareBehindSquare), + title: didCopy ? "Copied" : "Copy Invite Link", + accessibilityIdentifier: "group-invite-copy", + action: copy + ) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + .navigationTitle("Invite to Join Group") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(binding: $isPresented) + } + } + } + } + + private func copy() { + UIPasteboard.general.string = url.absoluteString + withAnimation(.easeInOut(duration: 0.15)) { + didCopy = true + } + Task { + try? await Task.sleep(for: Self.confirmationDuration) + withAnimation(.easeInOut(duration: 0.15)) { + didCopy = false + } + } + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift new file mode 100644 index 000000000..b327938c0 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift @@ -0,0 +1,87 @@ +// +// MinimumBalanceAmountSheet.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The custom minimum balance behind the form's `…` preset (node 10127:118014). +/// +/// Pinned to USD rather than the wallet's display currency: the gate weighs `StoredBalance.usdf`, +/// the holding's USD worth resolved at store time, so a requirement entered in USD needs no rate to +/// enforce and cannot drift with one. +struct MinimumBalanceAmountSheet: View { + + /// The amount the form already holds, so reopening the sheet starts on it rather than empty. + let initialAmount: FiatAmount? + + @Binding var isPresented: Bool + + let onSelect: (FiatAmount) -> Void + + @State private var enteredAmount: String = "" + + /// Whole dollars only past nine digits is what every other amount field allows; the two decimal + /// places match the currency the requirement is denominated in. + private static let rules = KeyPadView.CurrencyRules(maxIntegerDigits: 9, maxDecimalDigits: 2) + + private let validator = AmountValidator() + + var body: some View { + NavigationStack { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + Spacer() + + AmountField( + content: $enteredAmount, + defaultValue: .number("0"), + prefix: .none, + formatter: .fiat(currency: .usd, minimumFractionDigits: 0), + suffix: nil, + showChevron: false + ) + .foregroundStyle(enteredAmount.isEmpty ? Color.textTertiary : Color.textMain) + + Spacer() + + KeyPadView( + content: $enteredAmount, + configuration: .decimal(), + rules: Self.rules + ) + .padding(.bottom, 12) + + CodeButton(style: .filled, title: "Done", disabled: amount == nil) { + guard let amount else { return } + onSelect(amount) + isPresented = false + } + .accessibilityIdentifier("minimum-balance-done-button") + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .navigationTitle("Minimum Balance") + .toolbarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(binding: $isPresented) + } + } + } + .onAppear { + guard let initialAmount else { return } + enteredAmount = validator.string(from: initialAmount.value, fractionDigits: 2) + } + } + + /// The entered amount, or nil while the field holds nothing the requirement can be set to. + /// Parsed only through ``AmountValidator``, never from the raw keypad string. + private var amount: FiatAmount? { + guard let value = validator.validate(enteredAmount), value > 0 else { return nil } + return .usd(value) + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift new file mode 100644 index 000000000..e44ad260c --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupModel.swift @@ -0,0 +1,197 @@ +// +// NewPublicGroupModel.swift +// Flipcash +// + +import UIKit +import FlipcashCore + +/// Thrown when Create runs against an incomplete form. The button is disabled in that state, so +/// this is a programming error rather than something the screen puts in front of the user. +struct NewPublicGroupIncomplete: Error {} + +/// Everything the "New Public Group" form holds while it is being filled in, and the one call that +/// submits it (node 10127:118014). +/// +/// The balance requirement is denominated in USD. The presets the design offers are dollar amounts +/// and `StoredBalance.usdf` — the holding's USD worth, resolved at store time — is what the gate +/// weighs them against, so USD is the one denomination that needs no rate to enforce. +@MainActor +@Observable +final class NewPublicGroupModel { + + /// The title as typed. Submitted through ``GroupTitleValidator``, never raw. + var title: String = "" + + /// The picture the user picked, before it is encoded or uploaded. Optional — the contract + /// leaves the blob unset for a group with no picture. + private(set) var picture: UIImage? + + /// The holding the requirement is denominated in, kept whole so the form's row can draw the + /// token's name and icon. Only ``ExchangedBalance/stored``'s mint reaches the wire. + private(set) var selectedBalance: ExchangedBalance? + + /// Whether the mint in ``selectedBalance`` is one the user picked, rather than the one the form + /// opened on. + /// + /// The form opens already naming a mint, so "a mint is set" stopped meaning "the user chose + /// one" — and the requirement's heading turns on the choice rather than on the mint. The + /// opening frame reads "Minimum Balance Required" with a mint already named (node + /// 10127:118014); picking one turns it into "Balance Requirement", still with no amount + /// collected (node 10127:118194). + private(set) var hasChosenMint = false + + /// The minimum balance a member must hold, in USD. + private(set) var minimumBalance: FiatAmount? + + /// Whether a create is in flight, so the button stops taking taps. + private(set) var isCreating = false + + /// The key identifying this create attempt, minted when the user first taps Create and held + /// for every retry after. + /// + /// The server derives the chat's identity from the caller and this key, so a retry carrying a + /// key an earlier call already used returns the chat that call created rather than making a + /// second one — which is the whole point, since a response lost on the way back leaves the + /// client unable to tell a failed call from a successful one. A key minted at the call site + /// would be a fresh key on every retry and would buy nothing. + /// + /// Never reset. An attempt the server rejected bound no chat to the key, so carrying it into + /// an edited retry is both safe and what protects the one case that matters. + private(set) var attemptKey: UUID? + + /// The blob the picture landed in, held across retries for the reason ``attemptKey`` is: the + /// reservation signs the byte count, so a retry that re-encoded would be storing a second copy + /// of a picture the server already has. + private(set) var reservedBlobID: BlobID? + + /// The dollar amounts the form offers as one tap each, before the custom entry (node + /// 10127:118014). + static let presets: [FiatAmount] = [.usd(10), .usd(50), .usd(100)] + + /// Cap on the encoded picture, matching the profile photo's. + private static let maxUploadBytes = 2 * 1_024 * 1_024 + + private let validator = GroupTitleValidator() + + init() {} + + // MARK: - Form state - + + func select(picture: UIImage) { + self.picture = picture + } + + /// Seats the mint the form opens on, so the creator starts on a rule they can satisfy rather + /// than on a blank the design doesn't draw (node 10127:118014). + /// + /// Fills an empty slot only. It is a starting point, not a choice: it leaves ``hasChosenMint`` + /// alone, and once anything sits in the slot this does nothing, so it can't walk over the + /// user's own pick. + func seed(balance: ExchangedBalance?) { + guard selectedBalance == nil, let balance else { return } + selectedBalance = balance + } + + func select(balance: ExchangedBalance) { + selectedBalance = balance + hasChosenMint = true + } + + func select(minimumBalance: FiatAmount) { + self.minimumBalance = minimumBalance + } + + /// The title as it would be submitted, or nil while the form holds nothing the server accepts. + var validatedTitle: String? { + validator.validate(title) + } + + /// How many more Unicode scalars the title field accepts. + var remainingTitleScalars: Int { + validator.remaining(in: title) + } + + /// The rules the chat is created with, or nil while the requirement is incomplete. + /// + /// Only a listener requirement is set: it is the one the design collects, and the contract + /// applies speaker rules on top of listener rules, so leaving `speaker` unset means "anyone who + /// can read can send". The mint list carries exactly the one selected holding — the contract + /// caps it at one today, and an empty list would mean something else entirely (the requirement + /// applying across every mint). + var rules: ConversationRules? { + guard let minimumBalance, let mint = selectedBalance?.stored.mint else { return nil } + return ConversationRules( + listener: [.minimumBalance(MinimumBalanceRequirement(amount: minimumBalance, mints: [mint]))] + ) + } + + /// Whether the user clears the bar they just drew. + /// + /// Run through the same evaluator the join gate uses rather than a second balance comparison: + /// the server refuses a `StartChat` whose caller doesn't satisfy its own rules + /// (`RULES_NOT_SATISFIED`), and one copy of the rule is the only way the button and the server + /// can't disagree. True while the requirement is incomplete — there is nothing to fall short of + /// yet, and ``canCreate(session:rates:)`` gates on the requirement separately. + func satisfiesOwnRules(session: some ConversationGateReading, rates: [CurrencyCode: Rate]) -> Bool { + conversationGate(session: session, rules: rules, rates: rates).listener.isSatisfied + } + + /// Whether Create is enabled: a title the server will take, a requirement the form has finished + /// collecting, the creator clearing it, and nothing already in flight. + func canCreate(session: some ConversationGateReading, rates: [CurrencyCode: Rate]) -> Bool { + guard !isCreating, validatedTitle != nil, rules != nil else { return false } + return satisfiesOwnRules(session: session, rates: rates) + } + + // MARK: - Create - + + /// Uploads the picture if there is one, then creates the chat, and returns it. + /// + /// Throws `ErrorStartChat` for every server result the screen has copy for, `ErrorBlob` when + /// the picture is refused, and ``NewPublicGroupIncomplete`` when called against a form Create + /// should have been disabled for. + func create(using creator: some GroupChatCreating) async throws -> Conversation { + guard let title = validatedTitle, let rules else { + throw NewPublicGroupIncomplete() + } + + isCreating = true + defer { isCreating = false } + + let key = attemptKey ?? UUID() + attemptKey = key + + let blobID = try await uploadPictureIfNeeded(using: creator) + + return try await creator.startChat( + title: title, + pictureBlobID: blobID, + rules: rules, + idempotencyKey: key + ) + } + + /// Stores and finalizes the picture, returning the blob `StartChat` should carry, or nil when + /// the group has no picture. A rejected blob clears the reservation so a different picture + /// starts clean. + private func uploadPictureIfNeeded(using creator: some GroupChatCreating) async throws -> BlobID? { + if reservedBlobID == nil, let picture { + // Encode before storing: the reservation signs the byte count, so the bytes may not + // change afterwards. + let data = try await ImageEncoder.encodeForUpload(picture, maxBytes: Self.maxUploadBytes) + reservedBlobID = try await creator.storeBlob(data, mimeType: "image/jpeg") + } + + guard let blobID = reservedBlobID else { return nil } + + do { + try await creator.awaitBlobFinalization(blobID: blobID) + } catch ErrorBlob.rejected(let reason) { + reservedBlobID = nil + throw ErrorBlob.rejected(reason) + } + + return blobID + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift new file mode 100644 index 000000000..e492527a9 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -0,0 +1,429 @@ +// +// NewPublicGroupScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.new-public-group") + +/// The "New Public Group" form: a name, an optional picture, and the balance a member has to hold +/// to get in (node 10127:118014). +struct NewPublicGroupScreen: View { + + @Environment(Container.self) private var container + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ConversationController.self) private var conversationController + @Environment(RatesController.self) private var ratesController + @Environment(AppRouter.self) private var router + + @State private var model = NewPublicGroupModel() + @State private var isPickingPhoto = false + @State private var isPickingCurrency = false + @State private var isEnteringCustomAmount = false + @State private var dialog: DialogItem? + @State private var buttonState: ButtonState = .normal + + @FocusState private var isTitleFocused: Bool + + private var session: Session { sessionContainer.session } + + /// The mint the form opens on: the wallet's top card, which ``StoredBalance/walletOrder(_:_:)`` + /// puts at the largest USD worth. That is the same figure ``conversationGate`` weighs the rule + /// against — `StoredBalance.usdf` — so it is the mint the creator can draw the highest + /// requirement in, rather than the one they hold the most units of. + /// + /// Read from the same list ``SelectCurrencyScreen`` offers. A default drawn from a wider list + /// could open the form on a mint the picker won't show, leaving no way back to it once the + /// user opens the picker at all. + /// + /// Nil only for a wallet holding nothing giveable, which is the one case the row falls back to + /// a placeholder the design has no frame for. + private var defaultBalance: ExchangedBalance? { + session.balances(for: ratesController.rateForBalanceCurrency()).giveable().first + } + + var body: some View { + Background(color: .backgroundMain) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + nameCard + + // Only once the limit is close enough to explain a disabled Create, matching + // the display-name field's countdown. + if model.remainingTitleScalars < Self.countdownThreshold { + Text("\(model.remainingTitleScalars) characters") + .font(.default(size: 13, weight: .medium)) + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, 12) + .padding(.top, 8) + } + + requirementSection + .padding(.top, 37) + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + .scrollDismissesKeyboard(.interactively) + } + .navigationTitle("New Public Group") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + // The design puts Create in the bar rather than over the content (node + // 10127:118246), so the form scrolls under a control that stays put. + Button(action: create) { + createLabel + .foregroundStyle(.white) + } + .prominentButtonStyle() + // Disabled for every state but `.normal`, so the pill dims behind the spinner and + // the checkmark and a second tap can't start a second chat. + .disabled(!canCreate || !buttonState.isNormal) + .accessibilityIdentifier("new-group-create-button") + } + } + .dialog(item: $dialog) + .task { + model.seed(balance: defaultBalance) + } + .fullScreenCover(isPresented: $isPickingPhoto) { + ImagePickerWithEditor( + onImagePicked: model.select(picture:), + onDismiss: { isPickingPhoto = false } + ) + .ignoresSafeArea() + } + .sheet(isPresented: $isPickingCurrency) { + SelectCurrencyScreen( + isPresented: $isPickingCurrency, + // The check follows the requirement's mint, not the wallet's denomination — this + // screen is picking what the gate weighs, not what the balance is shown in. + isSelected: { $0.stored.mint == model.selectedBalance?.stored.mint } + ) { balance in + model.select(balance: balance) + } + } + .sheet(isPresented: $isEnteringCustomAmount) { + MinimumBalanceAmountSheet( + initialAmount: model.minimumBalance, + isPresented: $isEnteringCustomAmount + ) { amount in + model.select(minimumBalance: amount) + } + } + } + + @ViewBuilder private var createLabel: some View { + switch buttonState { + case .normal: + Text("Create") + case .loading: + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + case .success, .successText: + Image.system(.checkmark) + } + } + + // MARK: - Form - + + /// The picture and the name, which the design draws as one card rather than as a tile above a + /// field (node 10127:118271). + private var nameCard: some View { + HStack(spacing: 12) { + GroupPictureTile(image: model.picture) + .onTapGesture { isPickingPhoto = true } + .accessibilityIdentifier("new-group-photo-picker") + + TextField("Group Name (Required)", text: $model.title) + .font(.appBarButton) + .foregroundStyle(Color.textMain) + .focused($isTitleFocused) + .submitLabel(.done) + .disabled(model.isCreating) + .accessibilityIdentifier("new-group-title-field") + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + } + + private var requirementSection: some View { + VStack(alignment: .leading, spacing: 10) { + // The label names what is still being collected until a currency is picked, and the + // requirement itself after. Keyed on the pick rather than on the mint, because the + // form opens with one already named: node 10127:118014 reads "Minimum Balance + // Required" over "Jeffy", and node 10127:118194 reads "Balance Requirement" over a + // picked mint with the amount still uncollected. + Text(model.hasChosenMint ? "Balance Requirement" : "Minimum Balance Required") + .font(.default(size: 17, weight: .bold)) + .foregroundStyle(Color.textMain.opacity(0.5)) + .padding(.horizontal, 12) + + VStack(alignment: .leading, spacing: 10) { + mintRow + presets + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow, in: .rect(cornerRadius: Metrics.buttonRadius)) + + Text("People won't be able to join this chat if their balance is less than this amount") + .font(.default(size: 13, weight: .medium)) + .foregroundStyle(Color.textMain.opacity(0.5)) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12) + + // The server refuses a chat whose creator is short of its own rules, so say so here + // rather than spending the round trip to be told. + if model.rules != nil, !satisfiesOwnRules { + Text("You need this balance yourself to create the group") + .font(.default(size: 13, weight: .medium)) + .foregroundStyle(Color.textError) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12) + .accessibilityIdentifier("new-group-self-requirement-warning") + } + } + } + + /// The mint the requirement is denominated in, opening the currency picker (node + /// 10127:118254). + private var mintRow: some View { + Button { isPickingCurrency = true } label: { + HStack(spacing: 4) { + if let balance = model.selectedBalance { + RemoteImage(url: balance.stored.imageURL) + .frame(width: 20, height: 20) + .clipShape(Circle()) + + Text(balance.stored.name) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + } else { + // Only a wallet with nothing giveable gets here — every other form opens on + // `defaultBalance`. + Text("Select Currency") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + } + + Image.system(.chevronDown) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(model.isCreating) + .accessibilityIdentifier("new-group-currency-row") + } + + private var presets: some View { + HStack(spacing: 8) { + ForEach(NewPublicGroupModel.presets, id: \.self) { amount in + AmountPresetButton(isSelected: model.minimumBalance == amount) { + model.select(minimumBalance: amount) + } label: { + Text(amount.formattedDroppingZeroFraction()) + } + } + + AmountPresetButton( + isSelected: model.minimumBalance.map { !NewPublicGroupModel.presets.contains($0) } ?? false + ) { + isTitleFocused = false + isEnteringCustomAmount = true + } label: { + Image.system(.ellipsis) + } + .accessibilityLabel("Custom amount") + .accessibilityIdentifier("new-group-custom-amount-button") + } + } + + /// Shown only once the limit is close enough to explain a disabled Create. + private static let countdownThreshold = 10 + + private var satisfiesOwnRules: Bool { + model.satisfiesOwnRules(session: session, rates: ratesController.cachedRates) + } + + private var canCreate: Bool { + model.canCreate(session: session, rates: ratesController.cachedRates) + } + + // MARK: - Create - + + private func create() { + guard canCreate else { return } + + isTitleFocused = false + buttonState = .loading + + Task { + do { + let conversation = try await model.create( + using: SessionGroupChatCreator( + session: session, + flipClient: container.flipClient + ) + ) + + // Seated before the push so the screen it lands on reads the chat out of the store + // rather than refetching what `StartChat` already returned. + conversationController.seatCreatedGroup(conversation) + + buttonState = .success + // Same beat the rest of the app holds its checkmark for. + try? await Task.delay(milliseconds: 500) + + guard !Task.isCancelled else { return } + + // The form is replaced rather than stacked under the chat: coming back to a filled + // form for a group that now exists would only offer to create it twice. + router.popTopmost() + router.push(.tipConversation(conversation.id)) + + } catch { + buttonState = .normal + handle(error) + } + } + } + + private func handle(_ error: Error) { + guard !Task.isCancelled else { return } + + switch error { + case ErrorStartChat.titleModerated(let category): + logger.info("Group title moderation denied", metadata: ["category": "\(category)"]) + ErrorReporting.captureError(error, reason: "Group title moderation denied") + dialog = .error( + title: "This Name is Not Allowed", + subtitle: "Try a different group name" + ) + + case ErrorStartChat.pictureBlobNotAccepted: + logger.info("Group picture not accepted") + ErrorReporting.captureError(error, reason: "Group picture not accepted") + dialog = .error( + title: "This Photo Isn't Allowed", + subtitle: "Try a different photo" + ) + + case ErrorStartChat.rulesNotSatisfied: + // The client check ran against a balance that has since moved, or against a rate that + // had not landed. The server is the authority, so its answer is what is shown. + logger.info("Group creator does not satisfy their own rules") + ErrorReporting.captureError(error, reason: "Group creator does not satisfy their own rules") + dialog = .error( + title: "You Don't Meet This Requirement", + subtitle: "Lower the minimum balance or add funds" + ) + + case ErrorStartChat.invalidRules: + logger.error("Group rules rejected as invalid") + ErrorReporting.captureError(error, reason: "Group rules rejected as invalid") + dialog = .error( + title: "This Requirement Isn't Valid", + subtitle: "Try a different minimum balance" + ) + + case ErrorStartChat.denied: + logger.info("Group creation denied") + ErrorReporting.captureError(error, reason: "Group creation denied") + dialog = .error( + title: "You Can't Create a Group", + subtitle: "Try again later" + ) + + case let blobError as ErrorBlob: + logger.info("Group picture upload failed", metadata: ["error": "\(blobError)"]) + ErrorReporting.captureError(blobError, reason: "Group picture upload failed") + dialog = .profilePictureFailed(blobError) + + case let encoderError as ImageEncoderError: + logger.error("Failed to encode the group picture", metadata: ["error": "\(encoderError)"]) + ErrorReporting.captureError(encoderError, reason: "Failed to encode the group picture") + dialog = .imageProcessingFailed + + default: + logger.error("Failed to create group", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to create group") + dialog = .error( + title: "Couldn't Create This Group", + subtitle: "Try again" + ) + } + } +} + +// MARK: - GroupPictureTile - + +/// The group's picture, or the camera tile that stands in for one (node 10127:118272). +private struct GroupPictureTile: View { + + let image: UIImage? + + private static let size: CGFloat = 74 + + var body: some View { + Circle() + .fill(Color.backgroundRow) + .frame(width: Self.size, height: Self.size) + .overlay { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image.asset(.camera) + .renderingMode(.template) + .resizable() + .frame(width: 40, height: 40) + .foregroundStyle(Color.textSecondary) + } + } + .compositingGroup() + .clipShape(Circle()) + .overlay { Circle().strokeBorder(Color.white.opacity(0.17)) } + .contentShape(Circle()) + } +} + +// MARK: - AmountPresetButton - + +/// One of the minimum-balance presets, or the custom-entry ellipsis beside them (node +/// 10127:118261). The selected one inverts to a white fill. +private struct AmountPresetButton: View { + + let isSelected: Bool + let action: () -> Void + @ViewBuilder let label: () -> Label + + /// Node 10127:118260 — the preset row is 61pt tall. + private static var height: CGFloat { 61 } + + var body: some View { + Button(action: action) { + label() + .font(.default(size: 22, weight: .bold)) + .tracking(-0.88) + .foregroundStyle(isSelected ? Color.textAction : Color.textMain) + .frame(maxWidth: .infinity) + .frame(height: Self.height) + .background( + isSelected ? Color.action : Color.white.opacity(0.1), + in: .rect(cornerRadius: Metrics.buttonRadius) + ) + .contentShape(.rect(cornerRadius: Metrics.buttonRadius)) + } + .buttonStyle(.plain) + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift new file mode 100644 index 000000000..3722a0ccf --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift @@ -0,0 +1,42 @@ +// +// NewChatScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// What the `+` in the Chats bar opens: the two kinds of chat a user can start (node +/// 10127:117987). +struct NewChatScreen: View { + + @Environment(AppRouter.self) private var router + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 12) { + ChatActionRow( + icon: .asset(.group), + title: "Create a Public Group", + accessibilityIdentifier: "new-chat-public-group" + ) { + router.push(.newPublicGroup) + } + + ChatActionRow( + icon: .asset(.at), + title: "Find by Username", + accessibilityIdentifier: "new-chat-find-by-username" + ) { + router.push(.usernameLookup) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + .navigationTitle("New Chat") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index af8a9cfdd..1b0a3cc4e 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -90,7 +90,7 @@ private struct NewChatButton: View { var body: some View { Button { - router.push(.usernameLookup) + router.push(.newChat) } label: { // Bar items get their glass from the system on iOS 26, so this // carries no button style of its own. diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 13d9fbe29..ca8f3bac4 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -12,15 +12,20 @@ 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. /// -/// Read-only. Every action the design hangs here (node 10127:116723's invite link, and leave/mute) -/// needs a membership RPC the contract does not carry yet, so the screen states what the chat is -/// rather than offering buttons that cannot do anything. +/// It carries the two actions a member has over a group: handing out the invite link, and leaving. +/// The head card offers the invite as well, but only while the group is still empty (node +/// 10127:118280), so once anyone else has joined this is the only way to the link. struct ChatProfileScreen: View { let conversationID: ConversationID @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer + @Environment(\.dismiss) private var dismiss + + @State private var isInviting = false + @State private var isLeaving = false + @State private var dialogItem: DialogItem? private var conversation: Conversation? { conversationController.conversation(withID: conversationID) @@ -37,6 +42,14 @@ struct ChatProfileScreen: View { 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. + private var isMember: Bool { + guard let conversation, conversation.type == .group else { return false } + return conversationController.isMember(of: conversation) + } + var body: some View { Background(color: .backgroundMain) { VStack(spacing: 16) { @@ -64,14 +77,81 @@ struct ChatProfileScreen: View { } } + if isMember { + VStack(spacing: 0) { + Row(insets: rowInsets, accessory: .chevron) { + Image(systemName: "person.badge.plus") + .frame(minWidth: 45) + Text("Invite People To Join") + .foregroundStyle(.textMain) + } action: { + isInviting = true + } + .accessibilityIdentifier("chat-profile-invite") + + Row( + insets: rowInsets, + disabled: isLeaving, + accessory: isLeaving ? .loader(.textMain) : .chevron + ) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .frame(minWidth: 45) + Text("Leave Group") + .foregroundStyle(.textMain) + } action: { + dialogItem = leaveDialog() + } + .accessibilityIdentifier("chat-profile-leave") + } + .font(.appDisplayXS) + .padding(.top, 24) + } + Spacer() } .padding(.horizontal, 20) } .navigationTitle("") .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialogItem) + .sheet(isPresented: $isInviting) { + GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) + } .task { await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) } } + + private var rowInsets: EdgeInsets { + .init(top: 25, leading: 0, bottom: 25, trailing: 0) + } + + private func leaveDialog() -> DialogItem { + .alert( + 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") { + Task { await leave() } + } + DialogAction.cancel() + } + } + + /// Leaves, then pops back to the chat the user left from. The conversation stays in the store, + /// so what they land on is the same gated preview a non-member sees. + private func leave() async { + isLeaving = true + defer { isLeaving = false } + do { + try await conversationController.leave(conversationID: conversationID) + dismiss() + } catch { + sessionContainer.session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to leave this group. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to leave group") + } + } } diff --git a/Flipcash/Utilities/URL+Links.swift b/Flipcash/Utilities/URL+Links.swift index 583ac4ffa..a569a8a80 100644 --- a/Flipcash/Utilities/URL+Links.swift +++ b/Flipcash/Utilities/URL+Links.swift @@ -34,6 +34,27 @@ extension URL { return URL(string: "\(host)/\(userID.uuidString.lowercased())")! } + /// The invite link for a group chat — the only way into one, since a group + /// is not otherwise discoverable. + /// + /// There is no RPC that mints or resolves one: the link is built from the + /// chat's own id, whose group form is a UUID, and `Route` parses it back. + /// `app.flipcash.com` is covered by the associated-domains entitlement, so + /// the link opens the app rather than the web page when it is installed. + nonisolated static func groupChatInvite(for conversationID: ConversationID) -> URL { + URL(string: "https://app.flipcash.com/chat/\(conversationID.linkPathComponent)")! + } + + /// The in-app link a chat opens through, for the push tap that has no `target_url` to follow. + /// + /// The same id an invite link carries, routed the same way, so a group the user has not joined + /// lands on the gated screen from either entry point. `sendCash` picks the notification action's + /// destination instead of the transcript. + nonisolated static func chatDeepLink(for conversationID: ConversationID, sendCash: Bool = false) -> URL { + let base = "flipcash://chat/\(conversationID.linkPathComponent)" + return URL(string: sendCash ? "\(base)/send" : base)! + } + static var privacyPolicy: URL { URL(string: "https://www.flipcash.com/privacy")! } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index af8414162..794baf4ad 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -164,6 +164,9 @@ final class ChatService: Sendable { } await MainActor.run { completion(.success(Conversation(response.chat))) } } catch let error as RPCError { + // The six StartChat results all arrive as `.ok`-shaped responses, so a transport + // status is the one failure whose cause isn't in the result — name its code. + logger.error("Failed to start chat at the transport", metadata: ["code": "\(error.code)"]) await MainActor.run { completion(.failure(.from(transportError: error))) } } catch { await MainActor.run { completion(.failure(.unknown)) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift index 1bce3c653..e34a8e6b6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift @@ -30,11 +30,17 @@ public struct ChatGroupCard: Hashable, Sendable, Codable { /// a member too (node 10125:19256). public var requirement: String? - public init(title: String, avatarID: String, imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil) { + /// Whether the card offers "Invite People To Join" under the requirement (node 10127:118280). + /// Set for a group the viewer is a member of that no one else has joined yet: a link is the only + /// way into a group, so an empty one needs its own way to hand that link out. + public var showsInvite: Bool + + public init(title: String, avatarID: String, imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil, showsInvite: Bool = false) { self.title = title self.avatarID = avatarID self.imageData = imageData self.blurhash = blurhash self.requirement = requirement + self.showsInvite = showsInvite } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift index c73c10d93..490d6789e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationIdentifiers.swift @@ -39,10 +39,32 @@ public struct ConversationID: Hashable, Sendable, CustomStringConvertible { self.data = data } + /// Decodes the dashed-UUID form a group chat's id takes in an invite link. + /// Fails unless `string` is a well-formed UUID: `common.v1.ChatId.value` is + /// 16 bytes for a group, so only a group id has this form. + public init?(uuidString: String) { + guard let uuid = UUID(uuidString: uuidString) else { return nil } + self.data = uuid.data + } + public var proto: Flipcash_Common_V1_ChatId { .with { $0.value = data } } + /// The dashed-UUID text form of a group chat's 16-byte id, or nil for a DM's + /// 32-byte id, which is a hash and has no UUID form. + public var uuidString: String? { + guard let uuid = try? UUID(data: data) else { return nil } + return uuid.uuidString.lowercased() + } + + /// The `{id}` segment of a `/chat/{id}` link: the dashed UUID for a group, + /// the base64url form for a DM. One accessor so a link is built the same way + /// everywhere it is built, and ``Route`` has one inverse to parse. + public var linkPathComponent: String { + uuidString ?? base64URLEncoded + } + /// The base64url form the server uses in `/chat/{chatId}` deep links — /// the inverse of ``init(base64URLEncoded:)``. Unpadded, with the URL-safe /// `-`/`_` alphabet, so it drops straight into a `flipcash://chat/` path diff --git a/FlipcashCore/Sources/FlipcashCore/Validation/GroupTitleValidator.swift b/FlipcashCore/Sources/FlipcashCore/Validation/GroupTitleValidator.swift new file mode 100644 index 000000000..fa0cebb54 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Validation/GroupTitleValidator.swift @@ -0,0 +1,35 @@ +// +// GroupTitleValidator.swift +// FlipcashCore +// + +import Foundation + +/// Validates a group chat's title: 1–64 Unicode scalars of any script. +/// +/// Returns the title trimmed of leading and trailing whitespace. +public struct GroupTitleValidator: Validator { + + /// PGV `max_len` on `chat.v1.StartChatRequest.GroupChatParameters.title` + /// counts Unicode scalars, not grapheme clusters — one ZWJ emoji spends seven. + public static let maxScalars = 64 + + public init() {} + + public func validate(_ input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty, trimmed.unicodeScalars.count <= Self.maxScalars else { + return nil + } + + return trimmed + } + + /// Returns how many more Unicode scalars the title accepts, negative once it + /// has already exceeded the limit. + public func remaining(in input: String) -> Int { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + return Self.maxScalars - trimmed.unicodeScalars.count + } +} diff --git a/FlipcashTests/Chat/ConversationMembershipTests.swift b/FlipcashTests/Chat/ConversationMembershipTests.swift index 103b3a041..2c715e1c3 100644 --- a/FlipcashTests/Chat/ConversationMembershipTests.swift +++ b/FlipcashTests/Chat/ConversationMembershipTests.swift @@ -176,6 +176,23 @@ struct ConversationMembershipTests { #expect(controller.joinedGroups.map(\.id) == [.test(1)]) } + @Test("A leave the server has no record of still clears membership") + func notFoundLeaveClearsMembership() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.groupFeed = [group(1, lastActivity: 100)] + mock.leaveError = ErrorLeaveChat.notFound + let controller = makeController(mock, database: database) + try await controller.join(conversationID: .test(1)) + + try await controller.leave(conversationID: .test(1)) + + // The server holds no membership to remove, so the local flag is the stale one. + #expect(controller.joinedGroups.isEmpty) + #expect(try database.getGroupMemberships().isEmpty) + } + @Test("A DM is a member chat without ever joining") func dmIsAlwaysAMember() async throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/Chat/GroupPushRoutingTests.swift b/FlipcashTests/Chat/GroupPushRoutingTests.swift new file mode 100644 index 000000000..25423d04b --- /dev/null +++ b/FlipcashTests/Chat/GroupPushRoutingTests.swift @@ -0,0 +1,154 @@ +// +// GroupPushRoutingTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashAPI +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +/// A push tap and an invite link are two entry points into one routing decision: both carry the +/// same chat id, and for a group the signed-in user has not joined both must land on the gated +/// transcript rather than on a join-only detour. +@MainActor +@Suite("Group push routing") +struct GroupPushRoutingTests { + + /// The group's id in its contract form — 16 bytes, which is what makes it expressible as a UUID + /// in a link (`common.v1.ChatId.value`). + private static let groupUUID = UUID(uuidString: "3F2504E0-4F89-41D3-9A0C-0305E82C3301")! + + /// How that id spells itself in a link — `ConversationID.linkPathComponent` lowercases. + private static let groupPath = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + + private func groupID() throws -> ConversationID { + try #require(ConversationID(uuidString: Self.groupUUID.uuidString)) + } + + /// A CHAT push aimed at `conversationID`, in the shape `NotificationPayload` decodes: the + /// serialized `push.v1.Payload` base64'd under the APS custom-data key. + private func chatPush(for conversationID: ConversationID, type: Flipcash_Chat_V1_ChatType = .group) throws -> [AnyHashable: Any] { + let payload = Flipcash_Push_V1_Payload.with { + $0.category = .chat + $0.navigation = .with { $0.chatID = conversationID.proto } + $0.chatMetadata = .with { $0.type = type } + } + return ["aps": [NotificationPayload.userInfoKey: try payload.serializedData().base64EncodedString()]] + } + + /// Stands in for `Session` on the gate's read side. + private final class StubHoldings: ConversationGateReading { + var isStaff = false + var totalBalance = ExchangedFiat(nativeAmount: .usd(0), rate: Rate(fx: 1, currency: .usd)) + private let balances: [PublicKey: StoredBalance] + + init(balances: [PublicKey: StoredBalance] = [:]) { + self.balances = balances + } + + func balance(for mint: PublicKey) -> StoredBalance? { balances[mint] } + } + + 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 + ) + } + + // MARK: - Push tap → route + + @Test("A group push decodes to its chat id and taps through to the chat route") + func pushTapRoutesToTheChat() throws { + let conversationID = try groupID() + let userInfo = try chatPush(for: conversationID) + + #expect(NotificationPayload.chatID(userInfo) == conversationID) + #expect(NotificationPayload.chatType(userInfo) == .group) + + let tapped = try #require(NotificationPayload.chatID(userInfo)) + let url = URL.chatDeepLink(for: tapped) + #expect(url.absoluteString == "flipcash://chat/\(Self.groupPath)") + + guard case .chat(let parsed) = Route(url: url)?.path else { + Issue.record("A chat push's deep link should parse as .chat") + return + } + #expect(parsed == conversationID) + } + + @Test("The push tap and the invite link resolve to the same destination") + func pushAndInviteShareOneDestination() throws { + let conversationID = try groupID() + let fromPush = try #require(NotificationPayload.chatID(try chatPush(for: conversationID))) + + // `Route.Path` isn't Equatable, so the two are compared by what they carry. + guard case .chat(let fromTap) = Route(url: URL.chatDeepLink(for: fromPush))?.path, + case .chat(let fromInvite) = Route(url: URL.groupChatInvite(for: conversationID))?.path + else { + Issue.record("Both entry points should parse as .chat") + return + } + #expect(fromTap == fromInvite) + #expect(fromTap == conversationID) + + // `.group` is routed whether or not the viewer is a member — the screen gates itself. + #expect( + DeepLinkAction.chatDestination(for: .group, conversationID: conversationID) + == .tipConversation(conversationID) + ) + #expect(DeepLinkAction.chatDestination(for: .contactDm, conversationID: conversationID) == nil) + #expect(DeepLinkAction.chatDestination(for: nil, conversationID: conversationID) == nil) + } + + @Test("The Send Cash action keeps its own path off the same id") + func sendCashActionKeepsItsPath() throws { + let conversationID = try groupID() + let url = URL.chatDeepLink(for: conversationID, sendCash: true) + #expect(url.absoluteString == "flipcash://chat/\(Self.groupPath)/send") + + guard case .chatSendCash(let parsed) = Route(url: url)?.path else { + Issue.record("The Send Cash action should parse as .chatSendCash") + return + } + #expect(parsed == conversationID) + } + + // MARK: - What the destination shows a non-member + + @Test("An unjoined group whose bar the viewer is under lands blurred, not readable") + func unjoinedAndShortLandsGated() throws { + let rules = ConversationRules(listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(100), mints: [.usdf]))]) + let session = StubHoldings(balances: [.usdf: try holding(usd: 25)]) + let gate = conversationGate(session: session, rules: rules, rates: [:]) + + let presentation = conversationGatePresentation(gate, isMember: false) + #expect(presentation == .blocked(.minimumBalance(amount: .usd(100), mint: .usdf))) + #expect(presentation.obscuresTranscript) + #expect(presentation.replacesComposer) + } + + @Test("An unjoined group whose bar the viewer clears lands on the join, transcript unblurred") + func unjoinedAndClearLandsOnJoin() throws { + let requirement = MinimumBalanceRequirement(amount: .usd(100), mints: [.usdf]) + let rules = ConversationRules(listener: [.minimumBalance(requirement)]) + let session = StubHoldings(balances: [.usdf: try holding(usd: 150)]) + let gate = conversationGate(session: session, rules: rules, rates: [:]) + + let presentation = conversationGatePresentation(gate, isMember: false) + #expect(presentation == .join(.minimumBalance(amount: .usd(100), mint: .usdf))) + #expect(presentation.obscuresTranscript == false) + #expect(presentation.replacesComposer) + } +} diff --git a/FlipcashTests/Chat/NewPublicGroupModelTests.swift b/FlipcashTests/Chat/NewPublicGroupModelTests.swift new file mode 100644 index 000000000..87de9a16e --- /dev/null +++ b/FlipcashTests/Chat/NewPublicGroupModelTests.swift @@ -0,0 +1,321 @@ +// +// NewPublicGroupModelTests.swift +// FlipcashTests +// + +import Foundation +import UIKit +import Testing +@testable import Flipcash +import FlipcashCore +import FlipcashStore + +@MainActor +@Suite("New public group form") +struct NewPublicGroupModelTests { + + // MARK: - Doubles - + + /// Records what the form asked of the server, and answers with whatever the test set. + private final class SpyCreator: GroupChatCreating { + + private(set) var startChatCalls: [(title: String, blobID: BlobID?, rules: ConversationRules?, key: UUID)] = [] + private(set) var storeBlobCallCount = 0 + private(set) var finalizationCallCount = 0 + + /// Thrown by the next `startChat`, then cleared — so a test can fail one attempt and let + /// the retry through. + var nextStartChatError: Error? + /// Thrown by the next `awaitBlobFinalization`, then cleared. + var nextFinalizationError: Error? + + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { + storeBlobCallCount += 1 + return BlobID(data: Data(repeating: UInt8(storeBlobCallCount), count: 32)) + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + finalizationCallCount += 1 + if let error = nextFinalizationError { + nextFinalizationError = nil + throw error + } + } + + func startChat( + title: String, + pictureBlobID: BlobID?, + rules: ConversationRules?, + idempotencyKey: UUID + ) async throws -> Conversation { + startChatCalls.append((title, pictureBlobID, rules, idempotencyKey)) + if let error = nextStartChatError { + nextStartChatError = nil + throw error + } + return Conversation( + id: .test(7), + members: [], + lastMessage: nil, + lastActivity: Date(), + type: .group, + title: title, + rules: rules + ) + } + } + + /// Stands in for `Session`: a single USDF holding worth `usd`. + private final class StubHoldings: ConversationGateReading { + var isStaff = false + var totalBalance: ExchangedFiat + var holding: StoredBalance? + + init(usd: Decimal) { + totalBalance = ExchangedFiat(nativeAmount: .usd(usd), rate: Rate(fx: 1, currency: .usd)) + holding = 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 + ) + } + + func balance(for mint: PublicKey) -> StoredBalance? { + mint == .usdf ? holding : nil + } + } + + private let noRates: [CurrencyCode: Rate] = [:] + + /// A form filled in exactly as far as Create being enabled requires. + private func filledModel(minimum: Decimal = 100) -> NewPublicGroupModel { + let model = NewPublicGroupModel() + model.title = "BadBoys" + model.select(balance: .makeTest(mint: .usdf, quarks: 1_000_000)) + model.select(minimumBalance: .usd(minimum)) + return model + } + + // MARK: - The mint the form opens on - + + @Test("The mint the form opens on is seated but not counted as chosen") + func seededMintIsNotAChoice() { + let model = NewPublicGroupModel() + + model.seed(balance: .makeTest(mint: .usdf)) + + // The heading turns on this, not on the mint: node 10127:118014 names a mint and still + // reads "Minimum Balance Required". + #expect(model.selectedBalance?.stored.mint == .usdf) + #expect(model.hasChosenMint == false) + } + + @Test("Picking the mint the form opened on still counts as choosing it") + func choosingTheSeededMintCounts() { + let model = NewPublicGroupModel() + model.seed(balance: .makeTest(mint: .usdf)) + + model.select(balance: .makeTest(mint: .usdf)) + + #expect(model.hasChosenMint) + } + + @Test("Seeding never walks over the mint the user picked") + func seedLeavesAChoiceAlone() { + let model = NewPublicGroupModel() + model.select(balance: .makeTest(mint: .jeffy)) + + // Balances can land after the form is on screen, so a seed can arrive after a pick. + model.seed(balance: .makeTest(mint: .usdf)) + + #expect(model.selectedBalance?.stored.mint == .jeffy) + #expect(model.hasChosenMint) + } + + @Test("A wallet holding nothing giveable opens on no mint at all") + func seedingNothingLeavesTheSlotEmpty() { + let model = NewPublicGroupModel() + + model.seed(balance: nil) + + #expect(model.selectedBalance == nil) + #expect(model.hasChosenMint == false) + #expect(model.rules == nil, "Nothing to weigh the requirement in") + } + + // MARK: - Rules - + + @Test("The requirement maps to a single listener rule naming exactly the selected mint") + func rulesCarryOneListenerRequirementForTheSelectedMint() { + let model = filledModel() + + // The contract caps the mint list at one entry, and an empty list means something else — + // the requirement applying across every mint. + #expect(model.rules == ConversationRules( + listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(100), mints: [.usdf]))] + )) + // Speaker is left unset: anyone who can read can send. + #expect(model.rules?.speaker.isEmpty == true) + } + + @Test("The rules stay nil until both halves of the requirement are collected") + func rulesNilWhileRequirementIncomplete() { + let model = NewPublicGroupModel() + model.title = "BadBoys" + #expect(model.rules == nil) + + model.select(minimumBalance: .usd(100)) + #expect(model.rules == nil, "An amount with no mint names nothing to weigh") + + model.select(balance: .makeTest(mint: .usdf)) + #expect(model.rules != nil) + } + + // MARK: - The creator's own balance - + + @Test("Create stays disabled while the creator is short of the bar they drew") + func createDisabledWhenCreatorIsShort() { + let model = filledModel(minimum: 100) + let short = StubHoldings(usd: 99) + + #expect(model.satisfiesOwnRules(session: short, rates: noRates) == false) + #expect(model.canCreate(session: short, rates: noRates) == false) + } + + @Test("Create is enabled once the creator clears their own requirement") + func createEnabledWhenCreatorClearsTheBar() { + let model = filledModel(minimum: 100) + #expect(model.canCreate(session: StubHoldings(usd: 100), rates: noRates)) + } + + @Test("A title the validator rejects keeps Create disabled however healthy the balance is") + func createDisabledWithoutAValidTitle() { + let model = filledModel(minimum: 10) + model.title = " " + #expect(model.validatedTitle == nil) + #expect(model.canCreate(session: StubHoldings(usd: 10_000), rates: noRates) == false) + } + + @Test("An incomplete requirement keeps Create disabled") + func createDisabledWithoutARequirement() { + let model = NewPublicGroupModel() + model.title = "BadBoys" + #expect(model.canCreate(session: StubHoldings(usd: 10_000), rates: noRates) == false) + } + + // MARK: - Idempotency key - + + @Test("Every retry of one Create attempt carries the key the first attempt minted") + func retriesReuseTheFirstAttemptsKey() async throws { + let model = filledModel() + let creator = SpyCreator() + + creator.nextStartChatError = ErrorStartChat.transportFailure + await #expect(throws: ErrorStartChat.self) { try await model.create(using: creator) } + + _ = try await model.create(using: creator) + + #expect(creator.startChatCalls.count == 2) + #expect(creator.startChatCalls[0].key == creator.startChatCalls[1].key) + #expect(model.attemptKey == creator.startChatCalls[0].key) + } + + @Test("An edited retry keeps the key, so the server answers with the chat the first call bound") + func editedRetryKeepsTheKey() async throws { + let model = filledModel() + let creator = SpyCreator() + + creator.nextStartChatError = ErrorStartChat.transportFailure + await #expect(throws: ErrorStartChat.self) { try await model.create(using: creator) } + + // Parameters are not part of the chat's identity — the caller and the key are — so an + // edit must not mint a new one. + model.title = "BadBoys Reloaded" + _ = try await model.create(using: creator) + + #expect(creator.startChatCalls[0].key == creator.startChatCalls[1].key) + #expect(creator.startChatCalls[1].title == "BadBoys Reloaded") + } + + @Test("The key is minted at the first Create tap, not before") + func keyIsNotMintedWhileTheFormIsFilledIn() async throws { + let model = filledModel() + #expect(model.attemptKey == nil) + + _ = try await model.create(using: SpyCreator()) + #expect(model.attemptKey != nil) + } + + // MARK: - Submission - + + @Test("Create submits the validated title and the mapped rules") + func createSubmitsValidatedTitleAndRules() async throws { + let model = filledModel() + model.title = " BadBoys " + let creator = SpyCreator() + + let conversation = try await model.create(using: creator) + + #expect(creator.startChatCalls.first?.title == "BadBoys") + #expect(creator.startChatCalls.first?.rules == model.rules) + #expect(creator.startChatCalls.first?.blobID == nil, "A group with no picture carries no blob") + #expect(conversation.type == .group) + } + + @Test("Create against an incomplete form throws rather than calling the server") + func createOnIncompleteFormThrows() async { + let model = NewPublicGroupModel() + let creator = SpyCreator() + + await #expect(throws: NewPublicGroupIncomplete.self) { try await model.create(using: creator) } + #expect(creator.startChatCalls.isEmpty) + } + + @Test("A rejected picture clears the reservation so a different one starts clean") + func rejectedPictureClearsTheReservation() async throws { + let model = filledModel() + model.select(picture: UIImage.solid(.red)) + let creator = SpyCreator() + + creator.nextFinalizationError = ErrorBlob.rejected(.moderation) + await #expect(throws: ErrorBlob.self) { try await model.create(using: creator) } + #expect(model.reservedBlobID == nil) + #expect(creator.startChatCalls.isEmpty, "A picture the server refused never reaches StartChat") + + _ = try await model.create(using: creator) + #expect(creator.storeBlobCallCount == 2, "A cleared reservation re-stores") + } + + @Test("A picture the server accepted is stored once and reused across retries") + func acceptedPictureIsStoredOnce() async throws { + let model = filledModel() + model.select(picture: UIImage.solid(.blue)) + let creator = SpyCreator() + + creator.nextStartChatError = ErrorStartChat.transportFailure + await #expect(throws: ErrorStartChat.self) { try await model.create(using: creator) } + _ = try await model.create(using: creator) + + // The reservation signs the byte count, so re-encoding would store a second copy of a + // picture the server already holds. + #expect(creator.storeBlobCallCount == 1) + #expect(creator.startChatCalls[0].blobID == creator.startChatCalls[1].blobID) + } +} + +private extension UIImage { + /// A 16×16 fill, small enough that the encoder never has to downscale it. + static func solid(_ color: UIColor) -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16)).image { context in + color.setFill() + context.fill(CGRect(x: 0, y: 0, width: 16, height: 16)) + } + } +} diff --git a/FlipcashTests/RouteTests.swift b/FlipcashTests/RouteTests.swift index f56d0925b..65aa141d5 100644 --- a/FlipcashTests/RouteTests.swift +++ b/FlipcashTests/RouteTests.swift @@ -167,6 +167,48 @@ struct RouteTests { #expect(Route(url: URL(string: "https://app.flipcash.com/chat")!) == nil) } + @Test("A group invite link round-trips: the built URL parses back to the same chat") + func groupInviteLinkRoundTrip() { + // A group's ChatId is a 16-byte UUID, so its link carries the dashed form rather than the + // base64url a 32-byte DM id encodes to. + let uuid = UUID(uuidString: "3F2504E0-4F89-41D3-9A0C-0305E82C3301")! + let conversationID = ConversationID(uuidString: uuid.uuidString)! + + let url = URL.groupChatInvite(for: conversationID) + // Lowercased, because `linkPathComponent` is — one spelling, so two clients that build the + // same link produce the same string. Parsing stays case-insensitive. + #expect(url.absoluteString == "https://app.flipcash.com/chat/3f2504e0-4f89-41d3-9a0c-0305e82c3301") + + if case .chat(let parsed) = Route(url: url)?.path { + #expect(parsed == conversationID) + } else { + Issue.record("A built invite link should parse back as .chat") + } + + // The same id over the custom scheme, which is what a push tap re-enters through. + let deepLink = URL(string: "flipcash://chat/\(conversationID.linkPathComponent)")! + if case .chat(let parsed) = Route(url: deepLink)?.path { + #expect(parsed == conversationID) + } else { + Issue.record("A group deep link should parse as .chat") + } + } + + @Test("A group id keeps its send-cash path, and a 16-byte id that isn't a UUID still fails") + func groupSendCashAndMalformed() { + let conversationID = ConversationID(uuidString: "3F2504E0-4F89-41D3-9A0C-0305E82C3301")! + let url = URL(string: "https://app.flipcash.com/chat/\(conversationID.linkPathComponent)/send")! + + if case .chatSendCash(let parsed) = Route(url: url)?.path { + #expect(parsed == conversationID) + } else { + Issue.record("A group /chat/{uuid}/send should parse as .chatSendCash") + } + + // Neither a UUID nor 32 base64url bytes. + #expect(Route(url: URL(string: "https://app.flipcash.com/chat/3F2504E0-4F89-41D3")!) == nil) + } + @Test("Chat send-cash route parses /chat/{id}/send to .chatSendCash") func chatSendCashRoute() { let idData = Data((0..<32).map { UInt8($0) }) diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/Contents.json new file mode 100644 index 000000000..2fedf894a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "IconAt.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg new file mode 100644 index 000000000..30dd476f7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/Contents.json b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/Contents.json new file mode 100644 index 000000000..63892dc2c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "IconGroup3.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/IconGroup3.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/IconGroup3.svg new file mode 100644 index 000000000..19d3fbf37 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconGroup3.imageset/IconGroup3.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift index 370f237d2..818134b5b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift @@ -17,9 +17,9 @@ public final class ChatGroupCardCell: UICollectionViewCell { public static let reuseIdentifier = "ChatGroupCardCell" - public func configure(with card: ChatGroupCard, onTap: (() -> Void)? = nil) { + public func configure(with card: ChatGroupCard, onTap: (() -> Void)? = nil, onInvite: (() -> Void)? = nil) { contentConfiguration = UIHostingConfiguration { - GroupCardView(card: card, onTap: onTap) + GroupCardView(card: card, onTap: onTap, onInvite: onInvite) } .margins(.all, 0) } @@ -31,8 +31,34 @@ private struct GroupCardView: View { let card: ChatGroupCard /// Opens the chat's own profile; nil leaves the card inert. var onTap: (() -> Void)? + /// Hands out the chat's invite link. Drawn only when the card asks for it. + var onInvite: (() -> Void)? var body: some View { + VStack(spacing: Layout.inviteGap) { + cardBody + + if card.showsInvite, let onInvite { + Button(action: onInvite) { + Text("Invite People To Join") + .font(.appTextMedium) + .foregroundStyle(Color.textAction) + .padding(.horizontal, Layout.invitePadding) + .frame(height: Layout.inviteHeight) + .background(Color.action, in: .capsule) + .contentShape(.capsule) + } + .buttonStyle(.plain) + .accessibilityIdentifier("group-card-invite") + } + } + .frame(maxWidth: .infinity) + .padding(.top, 8) + } + + /// The bordered card itself: everything the group states about itself. The invite button sits + /// outside it — it is an action the viewer takes, not a fact about the chat (node 10127:118280). + private var cardBody: some View { VStack(spacing: 0) { header @@ -55,8 +81,6 @@ private struct GroupCardView: View { RoundedRectangle(cornerRadius: Layout.radius, style: .continuous) .strokeBorder(Color.white.opacity(Layout.borderOpacity)) } - .frame(maxWidth: .infinity) - .padding(.top, 8) .accessibilityElement(children: .combine) } @@ -108,6 +132,9 @@ private struct GroupCardView: View { static let horizontalPadding: CGFloat = 12 static let titleGap: CGFloat = 13 static let requirementGap: CGFloat = 11 + static let inviteGap: CGFloat = 12 + static let inviteHeight: CGFloat = 44 + static let invitePadding: CGFloat = 24 } } @@ -119,6 +146,12 @@ private struct GroupCardView: View { requirement: "Balance Requirement:\n$100.00 of $BadBoys" ), onTap: {}) GroupCardView(card: ChatGroupCard(title: "Flipcash Staff", avatarID: "staff")) + GroupCardView(card: ChatGroupCard( + title: "BadBoys", + avatarID: "badboys", + requirement: "Balance Requirement:\n$100.00 of $BadBoys", + showsInvite: true + ), onTap: {}, onInvite: {}) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.backgroundMain) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 97d4f92a7..2f27e99f4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -58,6 +58,12 @@ public final class ChatScreenViewController: UIViewController { public var isTranscriptObscured = false { didSet { transcriptBlur.isShown = isTranscriptObscured } } + + /// Whether to draw the gate's decorative shapes behind the blur — set when the chat is + /// obscured and there is nothing real under it. See ``GatePreviewPlaceholder``. + public var showsGatePlaceholder = false { + didSet { gatePlaceholder.isShown = showsGatePlaceholder } + } private var didFocusComposer = false /// Whether the composer held the keyboard when the current context menu opened, and so should get /// it back when that menu goes. Cleared by `dismissKeyboard()` so an action handing off to a sheet @@ -67,6 +73,9 @@ public final class ChatScreenViewController: UIViewController { private let backdrop = MessageBackdrop() /// The blur over a transcript the user is not allowed to read. See `TranscriptBlur`. private let transcriptBlur = TranscriptBlur() + /// The decorative shapes the blur softens when there is no transcript to soften. See + /// `GatePreviewPlaceholder`. + private let gatePlaceholder = GatePreviewPlaceholder() /// The row floated above a held blur, while an edit is open on it. private var editedStableID: String? /// Deferred attempts left at floating the edited message's copy. The menu's dismissal @@ -146,6 +155,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onProfileTap = newValue } } + /// Forwards the group head card's invite tap from the transcript to the owner. + public var onGroupInvite: (() -> Void)? { + get { transcript.onGroupInvite } + set { transcript.onGroupInvite = newValue } + } + /// Forwards gutter-avatar taps from the transcript to the owner — see /// ``ChatViewController/onAuthorTap``. public var onAuthorTap: ((UserID) -> Void)? { @@ -188,6 +203,8 @@ public final class ChatScreenViewController: UIViewController { // Above the transcript and below the bar clip, so the gate panel naming the unmet // requirement stays sharp over the transcript it is talking about. transcriptBlur.install(in: view, below: barClip) + // Under the blur, so the shapes are only ever seen through it. + gatePlaceholder.install(in: view, below: transcriptBlur.view) barHeightConstraint = constraints.height barClipHeightConstraint = constraints.clipHeight diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index efa96e88c..69731a02e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -55,6 +55,10 @@ public final class ChatViewController: UICollectionViewController { /// chat's own in a group. The owner opens that subject's profile; nil disables the tap. public var onProfileTap: (() -> Void)? + /// Called when the user taps the group card's "Invite People To Join"; the owner hands out the + /// chat's invite link. nil leaves the card without the offer. + public var onGroupInvite: (() -> Void)? + /// Called when the user taps an author's face in the gutter; the argument is that author's user /// id. Never fires in a DM, where no row draws one. public var onAuthorTap: ((UserID) -> Void)? @@ -387,7 +391,8 @@ public final class ChatViewController: UICollectionViewController { ) case .groupCard(let card): let cardTap: (() -> Void)? = onProfileTap == nil ? nil : { [weak self] in self?.onProfileTap?() } - (cell as! ChatGroupCardCell).configure(with: card, onTap: cardTap) + let invite: (() -> Void)? = onGroupInvite == nil ? nil : { [weak self] in self?.onGroupInvite?() } + (cell as! ChatGroupCardCell).configure(with: card, onTap: cardTap, onInvite: invite) case .dateSeparator(_, let text): (cell as! ChatDateSeparatorCell).configure(text: text) case .message(let message): diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift b/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift new file mode 100644 index 000000000..4ff6f1cb6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift @@ -0,0 +1,100 @@ +// +// GatePreviewPlaceholder.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI + +/// The shapes drawn behind the gate's blur for a chat the signed-in user has not joined (node +/// 10127:117171). +/// +/// Decorative, and deliberately so. The contract has no read for a non-member — `GetMessages` is +/// denied to anyone outside the roster — so there is nothing real to blur, and the design's soft +/// bubbles behind the gate panel are set dressing rather than a preview of the conversation. These +/// are fixed shapes: no message, no author and no timestamp is ever fetched to make them. +final class GatePreviewPlaceholder { + + /// Matches `TranscriptBlur`'s fade, so the shapes and the blur that softens them arrive + /// together rather than one under the other. + private static let duration: TimeInterval = 0.25 + + /// Each row as the design lays it out: the bubble's width as a fraction of the transcript's, + /// its height, and which side it hangs off. + private static let rows: [(width: CGFloat, height: CGFloat, isOutgoing: Bool)] = [ + (0.62, 40, false), + (0.45, 40, true), + (0.74, 62, false), + (0.38, 40, true), + (0.56, 40, false), + (0.66, 62, true), + (0.42, 40, false), + ] + + private static let horizontalInset: CGFloat = 16 + private static let rowSpacing: CGFloat = 8 + private static let radius: CGFloat = 18 + + private let container = UIView() + + /// Whether the shapes are drawn. Setting it to the value it already has is a no-op, so a + /// re-render reporting the same gate doesn't restart the fade. + var isShown = false { + didSet { + guard isShown != oldValue else { return } + UIView.animate(withDuration: Self.duration) { + self.container.alpha = self.isShown ? 1 : 0 + } + } + } + + /// Adds the shapes to `host`, pinned to its edges and layered directly under `sibling` — the + /// blur, which is the only reason they are legible as chat at all. Called once, while the + /// screen is being built; the shapes start hidden. + func install(in host: UIView, below sibling: UIView) { + container.translatesAutoresizingMaskIntoConstraints = false + container.alpha = 0 + // Purely decorative, so it never takes a touch that the transcript or the gate panel + // behind it would have handled. + container.isUserInteractionEnabled = false + host.insertSubview(container, belowSubview: sibling) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: host.topAnchor), + container.leadingAnchor.constraint(equalTo: host.leadingAnchor), + container.trailingAnchor.constraint(equalTo: host.trailingAnchor), + container.bottomAnchor.constraint(equalTo: host.bottomAnchor), + ]) + + let fill = UIColor(Color.white.opacity(0.08)) + var previous: UIView? + for row in Self.rows { + let bubble = UIView() + bubble.translatesAutoresizingMaskIntoConstraints = false + bubble.backgroundColor = fill + bubble.layer.cornerRadius = Self.radius + bubble.layer.cornerCurve = .continuous + container.addSubview(bubble) + + NSLayoutConstraint.activate([ + bubble.heightAnchor.constraint(equalToConstant: row.height), + bubble.widthAnchor.constraint( + equalTo: container.widthAnchor, + multiplier: row.width, + constant: -Self.horizontalInset + ), + row.isOutgoing + ? bubble.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Self.horizontalInset) + : bubble.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Self.horizontalInset), + bubble.topAnchor.constraint( + equalTo: previous?.bottomAnchor ?? container.safeAreaLayoutGuide.topAnchor, + constant: Self.rowSpacing + ), + ]) + previous = bubble + } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift index 7bcfb58fd..3352c1286 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift @@ -29,6 +29,9 @@ final class TranscriptBlur { private let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) + /// The blur itself, so anything meant to be seen *through* it can be layered directly beneath. + var view: UIView { effectView } + /// Whether the transcript is covered. Animates in and out; setting it to the value it already /// has is a no-op, so a re-render reporting the same gate doesn't restart the fade. var isShown = false { diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index 3ea67494a..ec66f9bf8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -49,6 +49,8 @@ public enum SystemSymbol: String { case circlePerson = "person.circle" case personBadgePlus = "person.badge.plus" case plus = "plus" + /// The three-dot glyph, for the custom minimum-balance button beside the presets. + case ellipsis = "ellipsis" case arrowUp = "arrow.up" case arrowDown = "arrow.down" @@ -195,6 +197,11 @@ public enum Asset: String, Sendable { case send case send2 case people = "IconPeople" + /// The pair-of-people glyph the "Create a Public Group" row draws (node 10127:118001). + case group = "IconGroup3" + /// The handle glyph the "Find by Username" and "Send Invite Link" rows draw + /// (nodes 10127:118008 and 10127:118328). + case at = "IconAt" case kikCode = "IconKikCode" case chainLink = "IconChainLink" case squareBehindSquare = "IconSquareBehindSquare" From b010d866c0d0e093fee8a36c1bc13322b0247054 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 11:31:41 -0400 Subject: [PATCH 02/14] fix(chat): withhold a gated transcript before its rules arrive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat opened by id — every invite link and every group push — is not in the store until `GetChat` returns, and the gate read that absence as "not a group, so nothing to gate". For the whole round trip a gated chat drew a sharp transcript and a live composer, then took both back when the metadata landed, which shows the viewer exactly what is being withheld from them. Adds `ConversationGatePresentation.undetermined` for rules that aren't known yet, distinct from `.open`, which is a chat that has told us it gates nothing. It blurs the transcript and draws no bar at all: a composer would be an affordance the server may refuse, and a gate panel would have to name a requirement we haven't been told. The placeholder shapes stay behind a separate `withholdsTranscript`, because standing in for messages the viewer may not see is a claim only `.blocked` can make. Both gated overlays also took their opacity from a hardcoded zero in `install`, which runs at `viewDidLoad` — after `makeUIViewController` has already assigned the gate. A chat that opened already gated rendered unblurred, with the shapes invisible underneath, until the gate value next changed. They now seed from `isShown`, and the blur snaps on and only animates off: withholding has to be immediate, while revealing is the transition. A dollar-token requirement now states its amount alone, in the gate panel and on the head card. "$100 of $USDF" says the same thing twice; any other token needs naming, because the same $100 is a different quantity of each. --- .../Conversation/ConversationBottomBar.swift | 12 ++++- .../Conversation/ConversationGate.swift | 37 ++++++++++++--- .../Conversation/ConversationGatePanel.swift | 44 ++++++++++++------ .../Conversation/ConversationScreen.swift | 46 +++++++++++++++---- FlipcashTests/ConversationGateTests.swift | 21 ++++++++- .../Chat/GatePreviewPlaceholder.swift | 8 +++- .../FlipcashUI/Chat/TranscriptBlur.swift | 32 +++++++++---- 7 files changed, 157 insertions(+), 43 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index a1c4dc60c..71364fef4 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -75,7 +75,14 @@ struct ConversationBottomBar: View { // The gate panel takes the bar whole rather than sitting inside it: none of the composer's // springs key on state a gated user can change, and the surface underneath is the same slab // either way, so the bar still reads as the bottom of the screen. - if gate.replacesComposer { + switch gate { + case .undetermined: + // Nothing is drawn at all until the rules land. A composer would be an affordance the + // server may refuse, and a panel would have to name a requirement we haven't been told. + // The bar arriving with the metadata is the honest version of both. + EmptyView() + + case .join, .blocked, .readOnly: ConversationGatePanel( presentation: gate, symbol: gateSymbol, @@ -84,7 +91,8 @@ struct ConversationBottomBar: View { isJoining: isJoiningChat ) .modifier(BarSurfaceBackground()) - } else { + + case .open: composerBar } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index aa5eabe6f..26b43435d 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -197,6 +197,16 @@ private func verdict(for unmet: [ConversationGateRequirement]) -> ConversationGa enum ConversationGatePresentation: Equatable { /// Nothing gated — the ordinary composer. case open + /// The chat's rules aren't known yet, because its metadata hasn't arrived. + /// + /// Distinct from ``open``, which is a chat that has told us it gates + /// nothing. A chat reached by link or push is not in the store until + /// `GetChat` returns, and treating that absence as "no rules" hands a + /// readable transcript and a working composer to someone who may be + /// entitled to neither, then takes both back when the metadata lands — + /// showing the thing being withheld. Withholding has to be immediate; + /// revealing is the part that can wait for an answer. + case undetermined /// Rules met but not yet a member: a readable transcript with Join Chat in /// place of the composer, carrying the chat's stated requirement to restate /// what joining costs (node 10125:19102). @@ -210,21 +220,36 @@ enum ConversationGatePresentation: Equatable { /// Whether the transcript is blurred and its messages left unfetched. /// - /// Only ``blocked``. Listener rules gate *reading*, so anyone who satisfies - /// them may read whether or not they have joined — which is why the design - /// shows a qualified non-member the real transcript with Join Chat under it. + /// ``blocked`` and ``undetermined``: a transcript the rules forbid, and one + /// whose rules haven't arrived. Not ``join`` — listener rules gate *reading*, + /// so anyone who satisfies them may read whether or not they have joined, + /// which is why the design shows a qualified non-member the real transcript + /// with Join Chat under it. var obscuresTranscript: Bool { switch self { case .open, .join, .readOnly: return false - case .blocked: return true + case .blocked, .undetermined: return true + } + } + + /// Whether the blur is covering a refusal rather than an unanswered question. + /// + /// The gate placeholder's shapes stand in for messages the viewer is not + /// allowed to see, which is a claim only ``blocked`` can make. Under + /// ``undetermined`` nothing is known to be withheld yet, so the blur covers + /// an empty screen and says nothing about why. + var withholdsTranscript: Bool { + switch self { + case .open, .join, .readOnly, .undetermined: return false + case .blocked: return true } } /// Whether the composer is replaced by a gate panel. var replacesComposer: Bool { switch self { - case .open: return false - case .join, .blocked, .readOnly: return true + case .open: return false + case .join, .blocked, .readOnly, .undetermined: return true } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index 8aa2daa23..32d986f5c 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -62,7 +62,7 @@ struct ConversationGatePanel: View { /// rules still shows the panel to a non-member, with Join Chat and nothing above it. private var requirement: ConversationGateRequirement? { switch presentation { - case .open: nil + case .open, .undetermined: nil case .join(let requirement): requirement case .blocked(let requirement): requirement case .readOnly(let requirement): requirement @@ -75,28 +75,44 @@ struct ConversationGatePanel: View { private var sentence: String? { guard let requirement else { return nil } switch requirement { - case .minimumBalance(let amount, _): - let holding = symbol.map { "\(amount.formattedDroppingZeroFraction()) of $\($0)" } - ?? amount.formattedDroppingZeroFraction() + case .minimumBalance(let amount, let mint): + let holding = requirementAmount(amount, mint: mint) switch presentation { - case .readOnly: return "Minimum Balance to Send Messages: \(holding)" - case .open, .join, .blocked: return "Minimum Balance: \(holding)" + case .readOnly: return "Minimum Balance to Send Messages: \(holding)" + case .open, .undetermined, .join, .blocked: return "Minimum Balance: \(holding)" } case .staff: switch presentation { - case .readOnly: return "Only Flipcash staff can send messages here" - case .open, .join, .blocked: return "This chat is for Flipcash staff" + case .readOnly: return "Only Flipcash staff can send messages here" + case .open, .undetermined, .join, .blocked: return "This chat is for Flipcash staff" } } } + /// The requirement's amount, naming the token it has to be held in unless that token is the + /// dollar one. + /// + /// Every requirement is denominated in dollars, so a dollar-token rule is already fully stated + /// by the amount — spelling the token out as well reads as "$100 of $USDF". A rule naming any + /// other token genuinely needs it: the same $100 is a different quantity of each. + private func requirementAmount(_ amount: FiatAmount, mint: PublicKey?) -> String { + let formatted = amount.formattedDroppingZeroFraction() + guard mint != .usdf, let symbol else { return formatted } + return "\(formatted) of $\(symbol)" + } + @ViewBuilder private var callToAction: some View { switch presentation { - case .open: + case .open, .undetermined: EmptyView() case .join: - Button("Join Chat", action: onJoin) + // The spinner is the whole of the in-flight state. There is no success hold to sit + // through: `join` seats membership from the reply it gets back, so the gate has already + // resolved to the composer by the time the call returns. + Button(action: onJoin) { + ButtonStateLabel("Join Chat", state: isJoining ? .loading : .normal) + } .buttonStyle(.filled) .disabled(isJoining) @@ -113,11 +129,11 @@ struct ConversationGatePanel: View { } /// Names the action in the token the chat asks for. A requirement spanning every mint is - /// satisfied by any holding, so it sends the user to add cash instead of to one token's buy - /// flow; a named mint whose metadata hasn't arrived yet still buys the right thing, it just - /// can't say which. + /// satisfied by any holding, and a dollar-token one by adding cash, so both send the user to + /// add cash rather than to a buy flow; a named mint whose metadata hasn't arrived yet still + /// buys the right thing, it just can't say which. private func addFundsTitle(mint: PublicKey?) -> String { - guard mint != nil else { return "Add Cash" } + guard let mint, mint != .usdf else { return "Add Cash" } guard let symbol else { return "Buy More" } return "Buy More $\(symbol)" } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 671fc224c..888189caa 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -235,10 +235,30 @@ struct ConversationScreen: View { /// Recomputed on each observation tick rather than cached, so a balance that crosses the /// requirement — or a rate that finally loads — opens the chat without a reopen. private var gate: ConversationGatePresentation { - guard let conversation = groupConversation else { return .open } + guard let conversation = groupConversation else { + return awaitingMetadata ? .undetermined : .open + } return conversationGatePresentation(gateVerdicts, isMember: conversationController.isMember(of: conversation)) } + /// Whether this chat's rules are simply unknown, rather than absent. + /// + /// A chat opened by its id — every invite link and every group push — is not in the store until + /// `GetChat` lands, and ``groupConversation`` is nil for the whole of that round trip. Reading + /// that nil as "not a group, so nothing to gate" is what would draw a readable transcript and a + /// live composer over a chat the viewer may not be allowed to read at all. A tip DM is excluded + /// because its chat genuinely does not exist yet: nothing is being withheld, and the first tip + /// is what creates it. + private var awaitingMetadata: Bool { + guard let conversationID else { return false } + switch context { + case .tipDM: + return false + case .existing: + return conversationController.conversation(withID: conversationID) == nil + } + } + /// The rule verdicts behind ``gate``, kept separately because the head card states the chat's /// advertised listener rule (``ConversationGate/headline``) whether or not the viewer is short /// of it, and the presentation only ever carries something unmet. @@ -353,8 +373,11 @@ struct ConversationScreen: View { /// the label rather than wherever the card's width falls, as the design breaks it. private var groupCardRequirement: String? { switch gateVerdicts.headline { - case .minimumBalance(let amount, _): - let symbol = headlineMint.flatMap { mintSymbols[$0] } + case .minimumBalance(let amount, let mint): + // A dollar-token rule is already fully stated by its dollar amount; naming the token + // as well reads as "$100 of $USDF". Any other token genuinely needs naming, because + // the same $100 is a different quantity of each. + let symbol = mint == .usdf ? nil : headlineMint.flatMap { mintSymbols[$0] } let holding = symbol.map { "\(amount.formattedDroppingZeroFraction()) of $\($0)" } ?? amount.formattedDroppingZeroFraction() return "Balance Requirement:\n\(holding)" @@ -369,7 +392,7 @@ struct ConversationScreen: View { /// across every holding, so there is no single token to buy. private var gateMint: PublicKey? { switch gate { - case .open: return nil + case .open, .undetermined: return nil case .join(let requirement): return requirement.flatMap(Self.mint(of:)) case .blocked(let requirement): return Self.mint(of: requirement) case .readOnly(let requirement): return Self.mint(of: requirement) @@ -448,8 +471,10 @@ struct ConversationScreen: View { startChattingFee: startChattingFee, gate: gate, // The contract has no non-member read, so a gated chat the viewer has no history of has - // nothing under its blur. The shapes stand in for what they are not allowed to see. - showsGatePlaceholder: gate.obscuresTranscript && (coordinator?.items.isEmpty ?? true), + // nothing under its blur. The shapes stand in for what they are not allowed to see — + // which is why this reads `withholdsTranscript` and not `obscuresTranscript`: a chat + // whose rules haven't landed yet is blurred without yet refusing anything. + showsGatePlaceholder: gate.withholdsTranscript && (coordinator?.items.isEmpty ?? true), gateSymbol: gateSymbol, onGateAddFunds: addFunds, onGateJoin: joinChat, @@ -822,11 +847,12 @@ struct ConversationScreen: View { } } - /// The gate panel's CTA: buys the mint the requirement names, or opens add-cash when the - /// requirement spans every holding and so has no one token to buy. Both routes leave the chat on - /// the stack, so satisfying the requirement returns to an ungated screen. + /// The gate panel's CTA: buys the mint the requirement names, or opens add-cash when there is no + /// one token to buy — a requirement spanning every holding, and a dollar-token one, which is + /// satisfied by adding cash rather than by swapping into the thing the cash already is. Both + /// routes leave the chat on the stack, so satisfying the requirement returns to an ungated screen. private func addFunds() { - guard let gateMint else { + guard let gateMint, gateMint != .usdf else { router.presentAddMoney(.general, source: .chat) return } diff --git a/FlipcashTests/ConversationGateTests.swift b/FlipcashTests/ConversationGateTests.swift index 33ef98589..185384091 100644 --- a/FlipcashTests/ConversationGateTests.swift +++ b/FlipcashTests/ConversationGateTests.swift @@ -268,12 +268,31 @@ struct ConversationGateTests { #expect(presentation.replacesComposer) } - @Test("Only a failing listener gate blurs and freezes the transcript") + @Test("Only an unreadable or unknown transcript is blurred and frozen") func presentation_obscuresTranscript_onlyWhenUnreadable() { #expect(ConversationGatePresentation.open.obscuresTranscript == false) #expect(ConversationGatePresentation.join(nil).obscuresTranscript == false) #expect(ConversationGatePresentation.readOnly(.staff).obscuresTranscript == false) #expect(ConversationGatePresentation.blocked(.staff).obscuresTranscript) + #expect(ConversationGatePresentation.undetermined.obscuresTranscript) + } + + // MARK: - Rules that haven't arrived + + @Test("A chat whose rules haven't arrived is covered, not opened") + func presentation_undetermined_coversEverything() { + let gate = ConversationGatePresentation.undetermined + #expect(gate.obscuresTranscript) + #expect(gate.replacesComposer) + } + + @Test("The placeholder shapes stand in for withheld messages, so only a refusal draws them") + func presentation_withholdsTranscript_onlyWhenBlocked() { + #expect(ConversationGatePresentation.blocked(.staff).withholdsTranscript) + #expect(ConversationGatePresentation.undetermined.withholdsTranscript == false) + #expect(ConversationGatePresentation.open.withholdsTranscript == false) + #expect(ConversationGatePresentation.join(nil).withholdsTranscript == false) + #expect(ConversationGatePresentation.readOnly(.staff).withholdsTranscript == false) } @Test("The stated requirement is the chat's own rule, not something the user is short of") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift b/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift index 4ff6f1cb6..2edf1306d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift @@ -53,10 +53,14 @@ final class GatePreviewPlaceholder { /// Adds the shapes to `host`, pinned to its edges and layered directly under `sibling` — the /// blur, which is the only reason they are legible as chat at all. Called once, while the - /// screen is being built; the shapes start hidden. + /// screen is being built. + /// + /// Takes its opacity from ``isShown`` for the same reason ``TranscriptBlur`` does: the gate is + /// assigned before the view loads, so a chat that opens already gated would otherwise blur an + /// empty screen with the shapes zeroed out underneath it. func install(in host: UIView, below sibling: UIView) { container.translatesAutoresizingMaskIntoConstraints = false - container.alpha = 0 + container.alpha = isShown ? 1 : 0 // Purely decorative, so it never takes a touch that the transcript or the gate panel // behind it would have handled. container.isUserInteractionEnabled = false diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift index 3352c1286..1d40aee9b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift @@ -24,30 +24,46 @@ import UIKit @MainActor final class TranscriptBlur { - /// Matches the bar's own swap between the composer and the gate panel, so the two land together. - private static let duration: TimeInterval = 0.25 + /// How long the blur takes to lift. Matches the bar's own swap between the gate panel and the + /// composer, so the two land together. + /// + /// Only the lift is animated. Covering the transcript happens in one frame: fading the blur in + /// animates readable messages into covered ones, which puts the thing being withheld on screen + /// for the length of the fade. Revealing has no such problem — by then the viewer is allowed to + /// see what is underneath, so the transition is free. + private static let revealDuration: TimeInterval = 0.25 private let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) /// The blur itself, so anything meant to be seen *through* it can be layered directly beneath. var view: UIView { effectView } - /// Whether the transcript is covered. Animates in and out; setting it to the value it already - /// has is a no-op, so a re-render reporting the same gate doesn't restart the fade. + /// Whether the transcript is covered. Snaps on and fades off — see ``revealDuration``. Setting + /// it to the value it already has is a no-op, so a re-render reporting the same gate doesn't + /// restart the fade. var isShown = false { didSet { guard isShown != oldValue else { return } - UIView.animate(withDuration: Self.duration) { - self.effectView.alpha = self.isShown ? 1 : 0 + guard !isShown else { + effectView.alpha = 1 + return + } + UIView.animate(withDuration: Self.revealDuration) { + self.effectView.alpha = 0 } } } /// Adds the blur to `host`, pinned to its edges and layered directly under `sibling`. Called - /// once, while the screen is being built; the blur starts hidden. + /// once, from `viewDidLoad`. + /// + /// Takes its opacity from ``isShown`` rather than starting hidden: the gate is assigned to the + /// controller before its view loads, so a chat that opens already gated has set this to true by + /// the time we get here, and zeroing the alpha would uncover the transcript until something + /// else changed the gate. func install(in host: UIView, below sibling: UIView) { effectView.translatesAutoresizingMaskIntoConstraints = false - effectView.alpha = 0 + effectView.alpha = isShown ? 1 : 0 // Takes the touches that would otherwise reach the transcript: a blurred view that still // scrolls reads as broken, and every fetch behind it is one the server would deny anyway. effectView.isUserInteractionEnabled = true From 5400dfd7c3f06f9036059449f24af1d18a490951 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 11:40:29 -0400 Subject: [PATCH 03/14] fix(chat): name the gate's token, not its ticker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate copy drew the mint's ticker behind a hardcoded `$` — "Minimum Balance: $100 of $JEFFY", "Buy More $JEFFY". The rest of the flow already names tokens: the create form's currency row draws `balance.stored.name`, so a creator who picks a token saw one string and everyone reading the rule they set saw another. Both gate surfaces — the panel and the head card — read the metadata's `name` now. The `$` a token's name carries still renders; it just comes from the name rather than from the format string. `mintSymbols` and the `gateSymbol` plumbing through `ChatScreenRepresentable` and `ConversationBottomBar` rename to match what they carry. Also moves a doc comment in `ChatScreenRepresentable` that had drifted onto the property above the one it describes. --- .../ChatScreenRepresentable.swift | 6 ++--- .../Conversation/ConversationBottomBar.swift | 7 +++--- .../Conversation/ConversationGatePanel.swift | 18 +++++++-------- .../Conversation/ConversationScreen.swift | 22 +++++++++---------- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index eb34e83f8..7316a99a9 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -71,11 +71,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Whether the chat's participation rules leave this user anything to type, and whether they /// may read at all. Drives the gate panel in place of the bar and the blur over the transcript. let gate: ConversationGatePresentation - /// Ticker for the mint the gate's requirement names, once resolved. /// Whether the gate's decorative shapes are drawn behind the blur — see /// ``GatePreviewPlaceholder``. Set for a chat the viewer cannot read and has no history of. let showsGatePlaceholder: Bool - let gateSymbol: String? + /// Display name of the mint the gate's requirement names, once resolved. + let gateMintName: String? /// Opens the buy or add-cash flow from the gate panel's CTA. let onGateAddFunds: () -> Void /// Joins the chat from the gate panel's Join button. @@ -211,7 +211,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { isTipDm: isTipDm, startChattingFee: startChattingFee, gate: gate, - gateSymbol: gateSymbol, + gateMintName: gateMintName, onGateAddFunds: onGateAddFunds, onGateJoin: onGateJoin, isJoiningChat: isJoiningChat diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 71364fef4..ca9e93c88 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -62,8 +62,9 @@ struct ConversationBottomBar: View { /// Whether the chat's participation rules leave this user anything to type. Anything but /// ``ConversationGatePresentation/open`` replaces the whole composer with the gate panel. var gate: ConversationGatePresentation = .open - /// Ticker for the mint the gate's requirement names, once resolved. See ``ConversationGatePanel``. - var gateSymbol: String? = nil + /// Display name of the mint the gate's requirement names, once resolved. See + /// ``ConversationGatePanel``. + var gateMintName: String? = nil /// Opens the buy or add-cash flow from the gate panel's CTA. var onGateAddFunds: () -> Void = {} /// Joins the chat from the gate panel's Join button. @@ -85,7 +86,7 @@ struct ConversationBottomBar: View { case .join, .blocked, .readOnly: ConversationGatePanel( presentation: gate, - symbol: gateSymbol, + mintName: gateMintName, onAddFunds: onGateAddFunds, onJoin: onGateJoin, isJoining: isJoiningChat diff --git a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index 32d986f5c..7159deffb 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -21,10 +21,10 @@ struct ConversationGatePanel: View { /// shows the composer instead. let presentation: ConversationGatePresentation - /// Ticker for the requirement's mint, once resolved. Nil for a requirement that names no mint — - /// it applies across every holding — and until the metadata lookup lands, so the copy drops the - /// "of $X" clause rather than printing a placeholder. - let symbol: String? + /// Display name of the requirement's mint, once resolved. Nil for a requirement that names no + /// mint — it applies across every holding — and until the metadata lookup lands, so the copy + /// drops the "of X" clause rather than printing a placeholder. + let mintName: String? /// Opens the buy flow for the requirement's mint, or add-cash when the requirement spans every /// mint. Never called for ``ConversationGateRequirement/staff``, which has no button. @@ -93,12 +93,12 @@ struct ConversationGatePanel: View { /// dollar one. /// /// Every requirement is denominated in dollars, so a dollar-token rule is already fully stated - /// by the amount — spelling the token out as well reads as "$100 of $USDF". A rule naming any + /// by the amount — spelling the token out as well says the same thing twice. A rule naming any /// other token genuinely needs it: the same $100 is a different quantity of each. private func requirementAmount(_ amount: FiatAmount, mint: PublicKey?) -> String { let formatted = amount.formattedDroppingZeroFraction() - guard mint != .usdf, let symbol else { return formatted } - return "\(formatted) of $\(symbol)" + guard mint != .usdf, let mintName else { return formatted } + return "\(formatted) of \(mintName)" } @ViewBuilder private var callToAction: some View { @@ -134,8 +134,8 @@ struct ConversationGatePanel: View { /// buys the right thing, it just can't say which. private func addFundsTitle(mint: PublicKey?) -> String { guard let mint, mint != .usdf else { return "Add Cash" } - guard let symbol else { return "Buy More" } - return "Buy More $\(symbol)" + guard let mintName else { return "Buy More" } + return "Buy More \(mintName)" } /// Node 10125:19197 — a 356pt card in a 402pt frame, 12pt above its contents and 6pt around diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 888189caa..c087fd93a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -70,7 +70,7 @@ struct ConversationScreen: View { @State private var coordinator: ConversationLoadCoordinator? /// Tickers for the mints the gate's copy names, resolved from mint metadata. Empty until they /// land, and for every ungated chat. - @State private var mintSymbols: [PublicKey: String] = [:] + @State private var mintNames: [PublicKey: String] = [:] /// Whether a `JoinChat` is in flight, so the gate panel's button stops taking taps. The join has /// no other UI state — it either seats membership, which re-resolves the gate, or it alerts. @State private var isJoiningChat = false @@ -375,10 +375,10 @@ struct ConversationScreen: View { switch gateVerdicts.headline { case .minimumBalance(let amount, let mint): // A dollar-token rule is already fully stated by its dollar amount; naming the token - // as well reads as "$100 of $USDF". Any other token genuinely needs naming, because + // as well says the same thing twice. Any other token genuinely needs naming, because // the same $100 is a different quantity of each. - let symbol = mint == .usdf ? nil : headlineMint.flatMap { mintSymbols[$0] } - let holding = symbol.map { "\(amount.formattedDroppingZeroFraction()) of $\($0)" } + let name = mint == .usdf ? nil : headlineMint.flatMap { mintNames[$0] } + let holding = name.map { "\(amount.formattedDroppingZeroFraction()) of \($0)" } ?? amount.formattedDroppingZeroFraction() return "Balance Requirement:\n\(holding)" case .staff: @@ -416,7 +416,7 @@ struct ConversationScreen: View { } /// Ticker for the requirement the panel names, once its metadata lands. - private var gateSymbol: String? { gateMint.flatMap { mintSymbols[$0] } } + private var gateMintName: String? { gateMint.flatMap { mintNames[$0] } } private static func mint(of requirement: ConversationGateRequirement) -> PublicKey? { switch requirement { @@ -475,7 +475,7 @@ struct ConversationScreen: View { // which is why this reads `withholdsTranscript` and not `obscuresTranscript`: a chat // whose rules haven't landed yet is blurred without yet refusing anything. showsGatePlaceholder: gate.withholdsTranscript && (coordinator?.items.isEmpty ?? true), - gateSymbol: gateSymbol, + gateMintName: gateMintName, onGateAddFunds: addFunds, onGateJoin: joinChat, isJoiningChat: isJoiningChat, @@ -536,15 +536,15 @@ struct ConversationScreen: View { // Name the gate's requirement in the token it asks 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: gateMints) { - var symbols: [PublicKey: String] = [:] + var names: [PublicKey: String] = [:] for mint in gateMints { if let stored = session.storedMintMetadata(for: mint) { - symbols[mint] = stored.symbol - } else if let fetched = try? await session.fetchMintMetadata(mint: mint).symbol { - symbols[mint] = fetched + names[mint] = stored.name + } else if let fetched = try? await session.fetchMintMetadata(mint: mint).name { + names[mint] = fetched } } - mintSymbols = symbols + mintNames = names } // Read what the device already knows about this chat's senders, for the ones its own roster // leaves out. One read per open: the local cache is not moving under an open transcript. From 72313d96db82ec0c6588f0a64f2422cacb8b3835 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 12:04:47 -0400 Subject: [PATCH 04/14] fix(chat): unblur a gated transcript on joining, not on eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-member who cleared a group's balance rule read the real transcript with Join Chat under it. Satisfying a chat's rules earns the right to join it, not the right to read it from outside — so `.join` now withholds the transcript the same way `.blocked` does, and membership is the only thing that lifts the blur. Eligibility still decides what the panel offers: Join Chat for a viewer who clears the bar, a buy for one who doesn't. Android draws the same line (code-payments/code-android-app#1479). --- .../Conversation/ConversationGate.swift | 41 +++++++++++-------- .../Chat/GroupPushRoutingTests.swift | 6 ++- FlipcashTests/ConversationGateTests.swift | 28 ++++++++++--- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index 26b43435d..14a57ba42 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -207,9 +207,16 @@ enum ConversationGatePresentation: Equatable { /// showing the thing being withheld. Withholding has to be immediate; /// revealing is the part that can wait for an answer. case undetermined - /// Rules met but not yet a member: a readable transcript with Join Chat in + /// Rules met but not yet a member: a blurred transcript with Join Chat in /// place of the composer, carrying the chat's stated requirement to restate - /// what joining costs (node 10125:19102). + /// what joining costs (node 10125:19102 for the panel's copy). + /// + /// The transcript is withheld here even though the listener rules are + /// satisfied, because joining is what unblurs: satisfying a chat's rules + /// earns the right to join it, not the right to read it from outside. + /// Eligibility decides what the button offers — Join Chat here, a buy in + /// ``blocked`` — never whether the messages are legible. Android draws the + /// same line. case join(ConversationGateRequirement?) /// Listener rules unmet: blurred transcript, the requirement named, and a /// CTA when the requirement has one (node 10125:19153). @@ -220,28 +227,29 @@ enum ConversationGatePresentation: Equatable { /// Whether the transcript is blurred and its messages left unfetched. /// - /// ``blocked`` and ``undetermined``: a transcript the rules forbid, and one - /// whose rules haven't arrived. Not ``join`` — listener rules gate *reading*, - /// so anyone who satisfies them may read whether or not they have joined, - /// which is why the design shows a qualified non-member the real transcript - /// with Join Chat under it. + /// Every state a non-member can be in: ``blocked`` refuses them, ``join`` + /// has not been accepted yet, and ``undetermined`` hasn't said. Membership + /// is the line, not eligibility — see ``join``. ``readOnly`` is not here + /// because that user has already joined; they just can't send. var obscuresTranscript: Bool { switch self { - case .open, .join, .readOnly: return false - case .blocked, .undetermined: return true + case .open, .readOnly: return false + case .join, .blocked, .undetermined: return true } } - /// Whether the blur is covering a refusal rather than an unanswered question. + /// Whether the blur is covering messages the viewer is being kept from, + /// rather than an unanswered question. /// - /// The gate placeholder's shapes stand in for messages the viewer is not - /// allowed to see, which is a claim only ``blocked`` can make. Under - /// ``undetermined`` nothing is known to be withheld yet, so the blur covers - /// an empty screen and says nothing about why. + /// The gate placeholder's shapes stand in for a transcript that exists and + /// is being withheld, which ``blocked`` and ``join`` can both claim: the + /// chat is real and its messages are not this viewer's to read yet. Under + /// ``undetermined`` nothing is known to be withheld, so the blur covers an + /// empty screen and says nothing about why. var withholdsTranscript: Bool { switch self { - case .open, .join, .readOnly, .undetermined: return false - case .blocked: return true + case .open, .readOnly, .undetermined: return false + case .join, .blocked: return true } } @@ -266,6 +274,7 @@ func conversationGatePresentation(_ gate: ConversationGate, isMember: Bool) -> C case .unsatisfied(_, let primary): return .blocked(primary) case .satisfied: + // Satisfying the rules earns the join, not the transcript — `.join` blurs. guard isMember else { return .join(gate.headline) } switch gate.speaker { case .unsatisfied(_, let primary): return .readOnly(primary) diff --git a/FlipcashTests/Chat/GroupPushRoutingTests.swift b/FlipcashTests/Chat/GroupPushRoutingTests.swift index 25423d04b..7730eef49 100644 --- a/FlipcashTests/Chat/GroupPushRoutingTests.swift +++ b/FlipcashTests/Chat/GroupPushRoutingTests.swift @@ -139,16 +139,18 @@ struct GroupPushRoutingTests { #expect(presentation.replacesComposer) } - @Test("An unjoined group whose bar the viewer clears lands on the join, transcript unblurred") + @Test("An unjoined group whose bar the viewer clears offers the join, still blurred") func unjoinedAndClearLandsOnJoin() throws { let requirement = MinimumBalanceRequirement(amount: .usd(100), mints: [.usdf]) let rules = ConversationRules(listener: [.minimumBalance(requirement)]) let session = StubHoldings(balances: [.usdf: try holding(usd: 150)]) let gate = conversationGate(session: session, rules: rules, rates: [:]) + // Clearing the bar changes what the panel offers, not what the viewer can read: + // joining is what unblurs. let presentation = conversationGatePresentation(gate, isMember: false) #expect(presentation == .join(.minimumBalance(amount: .usd(100), mint: .usdf))) - #expect(presentation.obscuresTranscript == false) + #expect(presentation.obscuresTranscript) #expect(presentation.replacesComposer) } } diff --git a/FlipcashTests/ConversationGateTests.swift b/FlipcashTests/ConversationGateTests.swift index 185384091..eba142347 100644 --- a/FlipcashTests/ConversationGateTests.swift +++ b/FlipcashTests/ConversationGateTests.swift @@ -268,15 +268,31 @@ struct ConversationGateTests { #expect(presentation.replacesComposer) } - @Test("Only an unreadable or unknown transcript is blurred and frozen") - func presentation_obscuresTranscript_onlyWhenUnreadable() { + @Test("Membership is what unblurs the transcript, not eligibility") + func presentation_obscuresTranscript_untilJoined() { #expect(ConversationGatePresentation.open.obscuresTranscript == false) - #expect(ConversationGatePresentation.join(nil).obscuresTranscript == false) #expect(ConversationGatePresentation.readOnly(.staff).obscuresTranscript == false) + #expect(ConversationGatePresentation.join(nil).obscuresTranscript) #expect(ConversationGatePresentation.blocked(.staff).obscuresTranscript) #expect(ConversationGatePresentation.undetermined.obscuresTranscript) } + @Test("Satisfying the rules changes the button, not the blur") + func presentation_eligibleNonMember_stillBlurred() { + let eligible = conversationGatePresentation(.open, isMember: false) + let refused = conversationGatePresentation( + ConversationGate( + listener: .unsatisfied(unmet: [.staff], primary: .staff), + speaker: .unsatisfied(unmet: [.staff], primary: .staff), + headline: .staff + ), + isMember: false + ) + #expect(eligible == .join(nil)) + #expect(eligible.obscuresTranscript) + #expect(refused.obscuresTranscript) + } + // MARK: - Rules that haven't arrived @Test("A chat whose rules haven't arrived is covered, not opened") @@ -286,12 +302,12 @@ struct ConversationGateTests { #expect(gate.replacesComposer) } - @Test("The placeholder shapes stand in for withheld messages, so only a refusal draws them") - func presentation_withholdsTranscript_onlyWhenBlocked() { + @Test("The placeholder shapes stand in for a transcript that exists and is withheld") + func presentation_withholdsTranscript_whenMessagesAreKeptBack() { #expect(ConversationGatePresentation.blocked(.staff).withholdsTranscript) + #expect(ConversationGatePresentation.join(nil).withholdsTranscript) #expect(ConversationGatePresentation.undetermined.withholdsTranscript == false) #expect(ConversationGatePresentation.open.withholdsTranscript == false) - #expect(ConversationGatePresentation.join(nil).withholdsTranscript == false) #expect(ConversationGatePresentation.readOnly(.staff).withholdsTranscript == false) } From 7f0be8232acf5b99561ec9f9ffdfbfbe9a5dd78b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 12:04:57 -0400 Subject: [PATCH 05/14] fix(chat): size the group invite sheet to its two rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite sheet presented at full height for a title and two rows, leaving most of the screen empty under them. It wraps its content now, like the app's other bottom sheets. `PartialSheet` measures the content it wraps, so the sheet carries its own title row rather than a `NavigationStack` — a stack would report a full-screen height and defeat the wrap. --- .../Tips/New Group/GroupInviteSheet.swift | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift index 99ef57bdd..d8b7dc9de 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift @@ -29,8 +29,10 @@ struct GroupInviteSheet: View { } var body: some View { - NavigationStack { - Background(color: .backgroundMain) { + PartialSheet(background: .backgroundMain) { + VStack(spacing: 20) { + header + VStack(spacing: 12) { ChatActionRow( icon: .asset(.at), @@ -46,22 +48,38 @@ struct GroupInviteSheet: View { accessibilityIdentifier: "group-invite-copy", action: copy ) - - Spacer() } - .padding(.horizontal, 16) - .padding(.top, 12) } - .navigationTitle("Invite to Join Group") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - CloseButton(binding: $isPresented) - } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) + } + } + + /// The sheet's own title bar. ``PartialSheet`` takes its height from the content it wraps, so + /// there is no navigation bar to hang the title and close button on — and a `NavigationStack` + /// here would report a full-screen height and defeat the wrap. + private var header: some View { + ZStack { + Text("Invite to Join Group") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + + HStack { + Spacer() + CloseButton(binding: $isPresented) + .foregroundStyle(Color.textMain) } } } + /// The design's 16 under the last row, less whatever the sheet already holds back for the home + /// indicator — stacking the two would triple the design's gap. + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 16 - reserved) + } + private func copy() { UIPasteboard.general.string = url.absoluteString withAnimation(.easeInOut(duration: 0.15)) { From 7e0e0b53546be5e2c96d826c6b27fc52aeb09852 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 12:04:57 -0400 Subject: [PATCH 06/14] fix(chat): return from a new group to the chat list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backing out of a freshly created group landed on the "New Chat" picker that opened the form. The form was already being popped so a filled one couldn't offer to create the same group twice; the picker behind it has the same problem in a weaker form, and neither is where the user was headed. The create flow unwinds to the chat list — this stack's root — before the chat is pushed, so back from the new group lands on the list that now holds it. --- .../Main/Tips/New Group/NewPublicGroupScreen.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift index e492527a9..26c698e7e 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -285,9 +285,11 @@ struct NewPublicGroupScreen: View { guard !Task.isCancelled else { return } - // The form is replaced rather than stacked under the chat: coming back to a filled - // form for a group that now exists would only offer to create it twice. - router.popTopmost() + // The whole create flow is replaced rather than stacked under the chat. Back from + // a group that now exists belongs at the chat list holding it, not at a filled + // form offering to create it twice or at the picker that opened the form. The + // chat list is this stack's root, so unwinding to it is the same as popping both. + router.popToRoot() router.push(.tipConversation(conversation.id)) } catch { From cee22f66f5553f6ca57f6e40b5052687c2c5003c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 12:17:52 -0400 Subject: [PATCH 07/14] test(chat): pin membership to the group feed, not to a chat fetched by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GetChat` returns the chat without the caller's relationship to it, so a group opened by invite link or push tap arrives with no membership answer. Recording that absence as a negative is the trap: it gates a member whose feed has not landed yet, and it outlives the round trip that would correct it. iOS avoids it by construction — membership is a positive set, and `hydratedConversation(withID:)` writes nothing to it — so an unanswered question stays unanswered until `GetGroupChatFeed` answers it. Nothing held that in place. Two tests do now: a hydration by id leaves the store and the membership table alone, and a later feed carrying the same group seats it. Android had to remove a persisted `false` to reach the same state (code-payments/code-android-app#1498), where a by-id fetch wrote the guess into the chat row. --- .../Chat/ConversationMembershipTests.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/FlipcashTests/Chat/ConversationMembershipTests.swift b/FlipcashTests/Chat/ConversationMembershipTests.swift index 2c715e1c3..fc72b00ef 100644 --- a/FlipcashTests/Chat/ConversationMembershipTests.swift +++ b/FlipcashTests/Chat/ConversationMembershipTests.swift @@ -106,6 +106,47 @@ struct ConversationMembershipTests { #expect(controller.joinedGroups.map(\.id) == [.test(1)]) } + // MARK: - Hydration by id + + @Test("A chat hydrated by its id records no membership, in the store or the cache") + func hydrationByIDRecordsNoMembership() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + // Reachable by `GetChat` and absent from the group feed: an invite link or a push tap into + // a group this device has never synced. + mock.feed = [group(9, lastActivity: 50)] + let controller = makeController(mock, database: database) + + let hydrated = try #require(await controller.hydratedConversation(withID: .test(9))) + + // `GetChat` returns the chat, not the caller's relationship to it, so there is nothing here + // to record. Writing the absence as a negative is the trap: it would gate a member whose + // feed has not landed yet, and it would outlive the round trip that could correct it. + // Membership is a positive set precisely so an unanswered question stays unanswered. + #expect(!controller.isMember(of: hydrated)) + #expect(try database.getGroupMemberships().isEmpty) + } + + @Test("The group feed, not the hydration, answers membership for a chat opened by id") + func groupFeedAnswersForAHydratedChat() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.feed = [group(9, lastActivity: 50)] + let controller = makeController(mock, database: database) + _ = await controller.hydratedConversation(withID: .test(9)) + + // The viewer was already in this group — joined on another device, or reinstalled. The + // hydration could not say so; the feed can, and does, without the screen asking again. + mock.groupFeed = [group(9, lastActivity: 50)] + await controller.loadGroupFeed() + + let hydrated = try #require(controller.conversation(withID: .test(9))) + #expect(controller.isMember(of: hydrated)) + #expect(try database.getGroupMemberships() == [.test(9)]) + } + // MARK: - Join and leave @Test("A join seats membership, metadata, and the cache") From d5a392295d7fc8f9d7c866c814026ab6d5b3e7c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 12:43:21 -0400 Subject: [PATCH 08/14] fix(chat): return to the chat list after leaving a group Leaving unwound one screen, landing on the chat the user had just left. The store still holds it, so they got the gated preview of a group they had chosen to be done with, with Join Chat offering to undo it. Pop to the stack's root instead. The chat list no longer lists the group, so unwinding there is the same as popping both screens. --- .../Core/Screens/Profile/ChatProfileScreen.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index ca8f3bac4..e4e2d366e 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -21,7 +21,7 @@ struct ChatProfileScreen: View { @Environment(ConversationController.self) private var conversationController @Environment(SessionContainer.self) private var sessionContainer - @Environment(\.dismiss) private var dismiss + @Environment(AppRouter.self) private var router @State private var isInviting = false @State private var isLeaving = false @@ -138,14 +138,18 @@ struct ChatProfileScreen: View { } } - /// Leaves, then pops back to the chat the user left from. The conversation stays in the store, - /// so what they land on is the same gated preview a non-member sees. + /// Leaves, then unwinds to the chat list. + /// + /// Popping one screen would land on the chat the user just left, which the store still holds — + /// so they'd be looking at the gated preview of a group they had chosen to be done with, with + /// Join Chat offering to undo it. The chat list is this stack's root, and it no longer lists + /// the group, so unwinding there is the same as popping both screens. private func leave() async { isLeaving = true defer { isLeaving = false } do { try await conversationController.leave(conversationID: conversationID) - dismiss() + router.popToRoot() } catch { sessionContainer.session.dialogItem = .error( title: "Something Went Wrong", From 5c6c501dca723c4f5a7bd1d35a396061455da6f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 12:43:22 -0400 Subject: [PATCH 09/14] fix(chat): frost a gated transcript the way a long press does The gate applied `.systemUltraThinMaterialDark` at full strength with nothing under it. The material is a translucent light veil, so over a near-black transcript it raised the black level rather than hiding it, and the result read lighter than the design and lighter than the blur the same bubbles get under a long press. Take that blur instead: the material at 40%, over black at 0.2. Under a context menu UIKit's own dimming supplies the dark; a gated transcript has no menu above it, so it carries the same stand-in an edit does. Both now read the fraction and the alpha from one `ChatBackdrop`, which also owns the paused-animator trick and its teardown contract, so the two blurs cannot drift apart. --- .../FlipcashUI/Chat/ChatBackdrop.swift | 54 +++++++++++++++++++ .../FlipcashUI/Chat/MessageBackdrop.swift | 31 +++-------- .../FlipcashUI/Chat/TranscriptBlur.swift | 34 ++++++++++-- 3 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift new file mode 100644 index 000000000..826d760a1 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBackdrop.swift @@ -0,0 +1,54 @@ +// +// ChatBackdrop.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit + +/// The frosting the chat screen puts over its own transcript — behind a lifted message's menu, +/// behind the edit that can follow it, and over a transcript the viewer is not allowed to read. +/// +/// One definition because it is one effect on screen: the gate blurs the same bubbles the long +/// press does, for a different reason, and anyone who meets both in a session sees any drift +/// between them. A recipe rather than a view, because the callers own their layers differently — +/// ``MessageBackdrop`` moves its blur between hosts mid-interaction, ``TranscriptBlur`` pins one to +/// a screen for its lifetime. +@MainActor +enum ChatBackdrop { + + /// How much of the material's blur is used. A `UIBlurEffect` has no radius to set — every style + /// is the same radius under a different tint — so the effect is applied through an animator that + /// is paused part-way, which is the only handle on its strength. At full strength the transcript + /// smears into flat colour; Android frosts the same screen at a 25dp radius and reads far softer, + /// and this is matched to that. + static let blurFraction: CGFloat = 0.4 + + /// The black that goes with the blur wherever nothing else is darkening the screen. + /// + /// The material is a translucent *light* veil, so over a near-black transcript it raises the + /// black level — blur on its own hands back something paler than the sharp transcript beside it. + /// Under a context menu UIKit's own dimming covers that: measured on the same patch of empty + /// transcript, rgb 23 under the menu against 44 after it. Everywhere else this stands in for it, + /// set well below the value that matches the menu exactly — matching it left an edit as dark as + /// the menu, which is heavier than Android's frosting of the same screen, and the menu's own + /// dimming isn't ours to lighten to meet it. + static let dimAlpha: CGFloat = 0.2 + + /// Applies the material to `view` at ``blurFraction``, and hands back the animator holding it + /// there. + /// + /// The animator is a dial, not an animation: it is never played out, but it has to be kept alive + /// for the effect to stay applied, and stopped by hand before it deallocates — a property + /// animator still active when it goes traps. Teardown belongs to the caller, which is the only + /// side that knows when its blur is finished with. + static func frost(_ view: UIVisualEffectView) -> UIViewPropertyAnimator { + let material = UIBlurEffect(style: .systemUltraThinMaterialDark) + let strength = UIViewPropertyAnimator(duration: 1, curve: .linear) { view.effect = material } + strength.fractionComplete = blurFraction + return strength + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index 6c64f6ea4..deb1ab678 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -28,20 +28,6 @@ final class MessageBackdrop { /// Matches the fade UIKit uses for its own dimming when no animator is supplied. private static let fallbackDuration: TimeInterval = 0.2 - /// Stands in for the dimming UIKit lays over the screen while a context menu is up, which goes - /// with the menu — measured on the same patch of empty transcript, rgb 23 under the menu against - /// 44 after it. Set well below the value that matches the menu exactly: matching it left an edit - /// as dark as the menu, which is heavier than Android's frosting of the same screen, and the - /// menu's own dimming isn't ours to lighten to meet it. - private static let heldDimAlpha: CGFloat = 0.2 - - /// How much of the material's blur is used. A `UIBlurEffect` has no radius to set — every style - /// is the same radius under a different tint — so the effect is applied through an animator that - /// is paused part-way, which is the only handle on its strength. At full strength the transcript - /// smears into flat colour; Android frosts the same screen at a 25dp radius and reads far softer, - /// and this is matched to that. - private static let blurFraction: CGFloat = 0.4 - /// Called when the held blur is tapped — the way out of an edit, as tapping outside the message /// is in WhatsApp. Never fires while a context menu owns the screen: the menu's own container /// sits above the blur and takes those taps. @@ -50,11 +36,9 @@ final class MessageBackdrop { /// Whether the blur is being kept past the menu that raised it. private(set) var isHeld = false - private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) private var effectView: UIVisualEffectView? - /// Holds the blur at `blurFraction`. Never played out — it is a dial, not an animation — but it - /// has to be kept alive and stopped by hand, since a property animator left active when it - /// deallocates traps. + /// Holds the blur at ``ChatBackdrop/blurFraction``, and is stopped by hand in `dismiss` and + /// `release` — see ``ChatBackdrop/frost(_:)``. private var blurStrength: UIViewPropertyAnimator? private var spotlight: UIView? /// Replaces the menu's dimming once the menu is gone. Lives inside the blur, so it is clipped @@ -81,12 +65,9 @@ final class MessageBackdrop { host.addSubview(blur) effectView = blur - // Applied part-way and left there, so the material never reaches full strength. Its duration - // is never played out; the fade is the view's own alpha, which is also what a partly-applied - // effect leaves available to animate. - let strength = UIViewPropertyAnimator(duration: 1, curve: .linear) { blur.effect = self.effect } - strength.fractionComplete = Self.blurFraction - blurStrength = strength + // The fade is the view's own alpha, which is also what a partly-applied effect leaves + // available to animate. + blurStrength = ChatBackdrop.frost(blur) let fadeIn = { blur.alpha = 1 } if let animator { @@ -124,7 +105,7 @@ final class MessageBackdrop { dim.isUserInteractionEnabled = false blur.contentView.addSubview(dim) self.dim = dim - UIView.animate(withDuration: Self.fallbackDuration) { dim.alpha = Self.heldDimAlpha } + UIView.animate(withDuration: Self.fallbackDuration) { dim.alpha = ChatBackdrop.dimAlpha } let clip = UIView() clip.clipsToBounds = true diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift index 1d40aee9b..bc9f6135f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift @@ -17,10 +17,10 @@ import UIKit /// being excluded — the title, the picture and the member count all stay readable above a blurred /// transcript, which is what the design shows. /// -/// Full material strength, unlike `MessageBackdrop`, which pauses a property animator part-way to -/// keep the transcript legible under an edit. Legibility is the opposite of the requirement here: -/// these are messages the user has no right to read, so the effect is applied outright and the -/// animator trick — and the deallocation trap that comes with keeping one paused — is not needed. +/// The frosting itself is ``ChatBackdrop``'s, the same one a long press puts behind a lifted +/// message, including its dim: this blur stands alone with no context menu above it, so nothing +/// else is darkening the screen, and the material on its own lifts a near-black transcript's blacks +/// instead of hiding them. @MainActor final class TranscriptBlur { @@ -33,7 +33,13 @@ final class TranscriptBlur { /// see what is underneath, so the transition is free. private static let revealDuration: TimeInterval = 0.25 - private let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) + private let effectView = UIVisualEffectView(effect: nil) + /// Layered in the effect view's content view, so it fades with the blur and needs no separate + /// animation of its own. + private let dim = UIView() + /// Holds the material at ``ChatBackdrop/blurFraction``. Kept for the life of the screen, and + /// stopped below before it deallocates — see ``ChatBackdrop/frost(_:)``. + private var strength: UIViewPropertyAnimator? /// The blur itself, so anything meant to be seen *through* it can be layered directly beneath. var view: UIView { effectView } @@ -67,13 +73,31 @@ final class TranscriptBlur { // Takes the touches that would otherwise reach the transcript: a blurred view that still // scrolls reads as broken, and every fetch behind it is one the server would deny anyway. effectView.isUserInteractionEnabled = true + + dim.translatesAutoresizingMaskIntoConstraints = false + dim.backgroundColor = .black + dim.alpha = ChatBackdrop.dimAlpha + dim.isUserInteractionEnabled = false + effectView.contentView.addSubview(dim) + host.insertSubview(effectView, belowSubview: sibling) NSLayoutConstraint.activate([ effectView.topAnchor.constraint(equalTo: host.topAnchor), effectView.leadingAnchor.constraint(equalTo: host.leadingAnchor), effectView.trailingAnchor.constraint(equalTo: host.trailingAnchor), effectView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + + dim.topAnchor.constraint(equalTo: effectView.contentView.topAnchor), + dim.leadingAnchor.constraint(equalTo: effectView.contentView.leadingAnchor), + dim.trailingAnchor.constraint(equalTo: effectView.contentView.trailingAnchor), + dim.bottomAnchor.constraint(equalTo: effectView.contentView.bottomAnchor), ]) + + strength = ChatBackdrop.frost(effectView) + } + + isolated deinit { + strength?.stopAnimation(true) } } #endif From 45092506d4a2d027aaadbe2eb7dc14cfbffe5e32 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 13:35:40 -0400 Subject: [PATCH 10/14] feat(groups): show the custom requirement on the fourth preset chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `…` chip drew an ellipsis whether or not a custom amount was set, so an amount entered on the keypad had nowhere to appear: the chip came back selected but blank while the three presets beside it showed their figures. It now takes the amount's face, and the ellipsis is what an unused slot looks like — the rule Android's chip row already follows. Preset labels are capped to one line and allowed to shrink with it, since four chips share the row and a custom amount can be wider than $100. --- .../Tips/New Group/NewPublicGroupScreen.swift | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift index 26c698e7e..ce0012054 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -234,19 +234,34 @@ struct NewPublicGroupScreen: View { } } - AmountPresetButton( - isSelected: model.minimumBalance.map { !NewPublicGroupModel.presets.contains($0) } ?? false - ) { + // The fourth slot is the keypad's, and a custom amount has nowhere else to show, so + // it takes this chip's face — the ellipsis is what an unused slot looks like. Same + // rule Android's chip row follows. + AmountPresetButton(isSelected: customAmount != nil) { isTitleFocused = false isEnteringCustomAmount = true } label: { - Image.system(.ellipsis) + if let customAmount { + Text(customAmount.formattedDroppingZeroFraction()) + } else { + Image.system(.ellipsis) + } } - .accessibilityLabel("Custom amount") + .accessibilityLabel( + customAmount.map { "Custom amount, \($0.formattedDroppingZeroFraction())" } ?? "Custom amount" + ) .accessibilityIdentifier("new-group-custom-amount-button") } } + /// The requirement when it is not one of the presets — what the fourth chip shows in place of + /// its ellipsis. + private var customAmount: FiatAmount? { + guard let amount = model.minimumBalance, + !NewPublicGroupModel.presets.contains(amount) else { return nil } + return amount + } + /// Shown only once the limit is close enough to explain a disabled Create. private static let countdownThreshold = 10 @@ -417,6 +432,10 @@ private struct AmountPresetButton: View { label() .font(.default(size: 22, weight: .bold)) .tracking(-0.88) + // Four chips share the row, so a custom amount wider than a preset shrinks to fit + // rather than wrapping or clipping. + .lineLimit(1) + .minimumScaleFactor(0.5) .foregroundStyle(isSelected ? Color.textAction : Color.textMain) .frame(maxWidth: .infinity) .frame(height: Self.height) From 6973ffa224d2b5ccf579b81e2ec5135f000a3ac0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 13:35:50 -0400 Subject: [PATCH 11/14] feat(groups): enter the balance requirement on the shared amount screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-amount sheet was its own stack — an `AmountField`, a keypad with two hardcoded decimal places and a button — pinned to USD and titled "Minimum Balance". Android opens the keypad the rest of the app enters amounts on, titles it "Balance Requirement", and prints the form's own sentence under a left-aligned amount. It now runs on `EnterAmountView` with an `EnterAmountHeader`, the pair the tip and Minimum To Chat screens use. Entry follows the account's display currency, and the keypad's rules come from that currency instead of from a constant. The requirement is still stored in USD, because the gate weighs `StoredBalance.usdf` and a USD requirement needs no rate to enforce. The one conversion happens on Done. Done stays disabled while the entry has no rate to convert through, and while it rounds away to nothing in USD, so neither can set a requirement nobody has to clear. `EnterAmountView.Mode` gains `.balanceRequirement`: nothing moves, so no send limit bounds it, and its action reads "Done". --- .../New Group/MinimumBalanceAmountSheet.swift | 118 ++++++++++-------- Flipcash/UI/EnterAmountCalculator.swift | 5 +- Flipcash/UI/EnterAmountView.swift | 10 +- 3 files changed, 80 insertions(+), 53 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift index b327938c0..6fa97ccae 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift @@ -7,64 +7,60 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The custom minimum balance behind the form's `…` preset (node 10127:118014). +/// The custom balance requirement behind the form's `…` preset (node 10127:118014). /// -/// Pinned to USD rather than the wallet's display currency: the gate weighs `StoredBalance.usdf`, -/// the holding's USD worth resolved at store time, so a requirement entered in USD needs no rate to -/// enforce and cannot drift with one. +/// Built on ``EnterAmountView``, the same screen the tip and Minimum To Chat flows enter amounts +/// on, so the entry is in the account's display currency and the keypad behaves the way it does +/// everywhere else. +/// +/// The requirement itself is still USD. The gate weighs `StoredBalance.usdf` — the holding's USD +/// worth resolved at store time — so a requirement stored in USD needs no rate to enforce and +/// cannot drift with one. The single conversion is the one applied here, at the moment the amount +/// is set, which is where Android puts it too. struct MinimumBalanceAmountSheet: View { - /// The amount the form already holds, so reopening the sheet starts on it rather than empty. + /// The requirement the form already holds, in USD, so reopening the sheet starts on it rather + /// than empty. let initialAmount: FiatAmount? @Binding var isPresented: Bool let onSelect: (FiatAmount) -> Void - @State private var enteredAmount: String = "" + @Environment(RatesController.self) private var ratesController - /// Whole dollars only past nine digits is what every other amount field allows; the two decimal - /// places match the currency the requirement is denominated in. - private static let rules = KeyPadView.CurrencyRules(maxIntegerDigits: 9, maxDecimalDigits: 2) + @State private var enteredAmount: String = "" + @State private var actionState: ButtonState = .normal private let validator = AmountValidator() + private var currency: CurrencyCode { ratesController.balanceCurrency } + var body: some View { NavigationStack { Background(color: .backgroundMain) { - VStack(spacing: 0) { - Spacer() - - AmountField( - content: $enteredAmount, - defaultValue: .number("0"), - prefix: .none, - formatter: .fiat(currency: .usd, minimumFractionDigits: 0), - suffix: nil, - showChevron: false - ) - .foregroundStyle(enteredAmount.isEmpty ? Color.textTertiary : Color.textMain) - - Spacer() - - KeyPadView( - content: $enteredAmount, - configuration: .decimal(), - rules: Self.rules - ) - .padding(.bottom, 12) - - CodeButton(style: .filled, title: "Done", disabled: amount == nil) { - guard let amount else { return } - onSelect(amount) - isPresented = false - } - .accessibilityIdentifier("minimum-balance-done-button") - } - .padding(.horizontal, 20) - .padding(.bottom, 20) + EnterAmountView( + mode: .balanceRequirement, + enteredAmount: $enteredAmount, + subtitle: .hidden, + actionState: $actionState, + actionEnabled: { _ in amount != nil }, + action: submit, + header: AnyView(EnterAmountHeader( + enteredAmount: $enteredAmount, + // The same sentence the form prints under the preset row: the sheet is + // setting the figure that sentence is about, and it is the only thing on + // screen saying what the amount does. + hint: .description( + "People won't be able to join this chat if their balance is less than this amount" + ) + )) + ) + .foregroundStyle(.textMain) + .padding(20) } - .navigationTitle("Minimum Balance") + .ignoresSafeArea(.keyboard) + .navigationTitle("Balance Requirement") .toolbarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -72,16 +68,40 @@ struct MinimumBalanceAmountSheet: View { } } } - .onAppear { - guard let initialAmount else { return } - enteredAmount = validator.string(from: initialAmount.value, fractionDigits: 2) - } + .onAppear(perform: seed) } - /// The entered amount, or nil while the field holds nothing the requirement can be set to. - /// Parsed only through ``AmountValidator``, never from the raw keypad string. + /// The entry restated in USD, or nil while the field holds nothing the requirement can be set + /// to. Parsed only through ``AmountValidator``, never from the raw keypad string. + /// + /// Nil as well when the rate the conversion needs hasn't landed, and when the entry is small + /// enough to round away to nothing in USD — both would otherwise set a requirement no one has + /// to clear. Done is disabled in every one of those states, so a zero can't be submitted. private var amount: FiatAmount? { - guard let value = validator.validate(enteredAmount), value > 0 else { return nil } - return .usd(value) + guard let value = validator.validate(enteredAmount), value > 0, + let usd = FiatAmount(value: value, currency: currency) + .converted(to: .usd, rates: ratesController.cachedRates), + usd.isPositive else { return nil } + return usd + } + + private func submit() { + guard let amount else { return } + onSelect(amount) + isPresented = false + } + + /// Starts the field on the requirement already set, restated in the currency being entered. + /// + /// Seeded on appear rather than in `init`, which has no rates to convert with. A requirement + /// whose currency has no rate seeds nothing rather than a figure in the wrong denomination. + private func seed() { + guard let initialAmount, + let native = initialAmount.converted(to: currency, rates: ratesController.cachedRates), + native.isPositive else { return } + enteredAmount = validator.string( + from: native.value, + fractionDigits: currency.maximumFractionDigits + ) } } diff --git a/Flipcash/UI/EnterAmountCalculator.swift b/Flipcash/UI/EnterAmountCalculator.swift index 8a4125727..1ebc9f349 100644 --- a/Flipcash/UI/EnterAmountCalculator.swift +++ b/Flipcash/UI/EnterAmountCalculator.swift @@ -23,7 +23,7 @@ nonisolated struct EnterAmountCalculator { var currency: CurrencyCode { switch mode { - case .currency, .buy, .sell, .convert, .withdraw, .addMoney, .minimumTip: + case .currency, .buy, .sell, .convert, .withdraw, .addMoney, .minimumTip, .balanceRequirement: selectedCurrency } } @@ -53,6 +53,9 @@ nonisolated struct EnterAmountCalculator { // Not a transaction — the only bound is the minimum, which the // screen states itself. return nil + case .balanceRequirement: + // Not a transaction — nothing moves, so no send limit bounds it. + return nil } } diff --git a/Flipcash/UI/EnterAmountView.swift b/Flipcash/UI/EnterAmountView.swift index cce663dd4..66a659476 100644 --- a/Flipcash/UI/EnterAmountView.swift +++ b/Flipcash/UI/EnterAmountView.swift @@ -220,17 +220,20 @@ extension EnterAmountView { /// Setting the minimum tip another user must pay to DM you. Not a /// transaction, so no send limit applies. case minimumTip + /// Setting the balance a member has to hold to get into a group. Not a + /// transaction either — nothing moves — so no send limit applies. + case balanceRequirement fileprivate func formatter(with currency: CurrencyCode) -> NumberFormatter { switch self { - case .currency, .withdraw, .buy, .sell, .convert, .addMoney, .minimumTip: + case .currency, .withdraw, .buy, .sell, .convert, .addMoney, .minimumTip, .balanceRequirement: return .fiat(currency: currency, minimumFractionDigits: 0) } } fileprivate var defaultValue: AmountField.DefaultValue { switch self { - case .currency, .withdraw, .buy, .sell, .convert, .addMoney, .minimumTip: return .number("0") + case .currency, .withdraw, .buy, .sell, .convert, .addMoney, .minimumTip, .balanceRequirement: return .number("0") } } @@ -243,12 +246,13 @@ extension EnterAmountView { case .convert: return "Next" case .addMoney: return "Add Money" case .minimumTip: return "Save" + case .balanceRequirement: return "Done" } } fileprivate var buttonStyle: CodeButton.Style { switch self { - case .currency, .withdraw, .buy, .sell, .convert, .addMoney, .minimumTip: return .filled + case .currency, .withdraw, .buy, .sell, .convert, .addMoney, .minimumTip, .balanceRequirement: return .filled } } } From f1c11a7e18455f9ac994fbfd3439615a15034cf0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 15:30:42 -0400 Subject: [PATCH 12/14] feat(chat): send a group invite with an invitation, not a bare link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group is not discoverable, so the invite link is the only way in — and it arrived with nothing beside it, leaving the recipient a URL to judge on its own. The share now leads with the group's name: Join Pizza Club on Flipcash and let's chat https://app.flipcash.com/chat/... Built as a `UIActivityItemSource` on the `ShareCashLinkItem` shape, including its split: AirDrop and Slack render a link themselves and would print the sentence as stray text beside it, so both still receive the bare URL. The name comes from the chat record rather than `displayName(for:)`, which resolves an untitled chat to a counterpart's name or to "Flipcash User" — either would be worse than no name. An untitled group drops the sentence whole rather than inviting to "Join on Flipcash", which is the rule Android's invite follows. Copy Invite Link still copies the URL alone. --- .../Tips/New Group/GroupInviteSheet.swift | 16 +++- Flipcash/Share/GroupInviteShareItem.swift | 85 +++++++++++++++++++ .../Chat/GroupInviteShareItemTests.swift | 67 +++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Share/GroupInviteShareItem.swift create mode 100644 FlipcashTests/Chat/GroupInviteShareItemTests.swift diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift index d8b7dc9de..025065158 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift @@ -18,6 +18,8 @@ struct GroupInviteSheet: View { @Binding var isPresented: Bool + @Environment(ConversationController.self) private var conversationController + /// How long the copy row stays on its checkmark before reverting — the beat the tip-card link /// row holds. private static let confirmationDuration: Duration = .seconds(1.5) @@ -28,6 +30,16 @@ struct GroupInviteSheet: View { .groupChatInvite(for: conversationID) } + /// The group's own title, which names it in the shared message. + /// + /// Read from the record rather than through `displayName(for:)`: that resolves an untitled chat + /// to a counterpart's name or to "Flipcash User", and a group invited to under either of those + /// would be worse than one invited to with no name at all. Nil shares the bare link — see + /// ``GroupInviteShareItem``. + private var title: String? { + conversationController.conversation(withID: conversationID)?.title + } + var body: some View { PartialSheet(background: .backgroundMain) { VStack(spacing: 20) { @@ -39,7 +51,9 @@ struct GroupInviteSheet: View { title: "Send Invite Link", accessibilityIdentifier: "group-invite-send" ) { - ShareSheet.present(activityItems: [url]) { _ in } + ShareSheet.present( + activityItem: GroupInviteShareItem(url: url, title: title) + ) { _ in } } ChatActionRow( diff --git a/Flipcash/Share/GroupInviteShareItem.swift b/Flipcash/Share/GroupInviteShareItem.swift new file mode 100644 index 000000000..56198f555 --- /dev/null +++ b/Flipcash/Share/GroupInviteShareItem.swift @@ -0,0 +1,85 @@ +// +// GroupInviteShareItem.swift +// Flipcash +// + +import UIKit +import LinkPresentation +import FlipcashCore + +/// Shares a group's invite link with a one-line invitation ahead of it. +/// +/// The sentence is payload rather than decoration. A group is not discoverable, so a link arriving +/// on its own gives the recipient nothing to judge it by; naming the group says what they are being +/// invited to before they tap. +/// +/// AirDrop and Slack get the bare URL. Both render a link themselves and would otherwise show the +/// sentence as literal text beside it — the same split ``ShareCashLinkItem`` makes. +final class GroupInviteShareItem: NSObject, UIActivityItemSource { + + let url: URL + + private let content: String + private let invitation: String? + + // MARK: - Init - + + /// - Parameters: + /// - url: the group's invite link. + /// - title: the group's name, or nil while it has none. Without one there is nothing to name, + /// so the share falls back to the bare link rather than a sentence with a hole in it — + /// the rule Android's invite follows. + init(url: URL, title: String?) { + self.url = url + + let invitation: String? + if let name = title?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + invitation = "Join \(name) on Flipcash and let's chat" + } else { + invitation = nil + } + + self.invitation = invitation + self.content = invitation.map { "\($0)\n\n\(url.absoluteString)" } ?? url.absoluteString + + super.init() + } + + // MARK: - UIActivityItemSource - + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + return "" + } + + func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { + return invitation ?? "" + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + guard let activityType else { + return content + } + + switch activityType.rawValue { + case "com.tinyspeck.chatlyio.share": // Slack + return url + default: + break + } + + switch activityType { + case .airDrop: + return url + default: + return content + } + } + + func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? { + let metadata = LPLinkMetadata() + metadata.originalURL = url + metadata.url = url + metadata.title = invitation ?? "Invite to Join Group" + return metadata + } +} diff --git a/FlipcashTests/Chat/GroupInviteShareItemTests.swift b/FlipcashTests/Chat/GroupInviteShareItemTests.swift new file mode 100644 index 000000000..c237abf62 --- /dev/null +++ b/FlipcashTests/Chat/GroupInviteShareItemTests.swift @@ -0,0 +1,67 @@ +// +// GroupInviteShareItemTests.swift +// FlipcashTests +// + +import Testing +import UIKit +import Foundation +@testable import Flipcash + +/// What a group invite carries into each destination: the invitation and the link, or the bare link +/// where the invitation would read as stray text. +@MainActor +@Suite("GroupInviteShareItem") +struct GroupInviteShareItemTests { + + private let url = URL(string: "https://app.flipcash.com/chat/6f9619ff-8b86-d011-b42d-00c04fc964ff")! + + private func item(title: String?) -> GroupInviteShareItem { + GroupInviteShareItem(url: url, title: title) + } + + private func sharedText(_ item: GroupInviteShareItem, to activityType: UIActivity.ActivityType? = nil) -> Any? { + item.activityViewController(UIActivityViewController(activityItems: [item], applicationActivities: nil), itemForActivityType: activityType) + } + + @Test("A named group is invited to by name, above the link") + func namedGroupCarriesTheInvitation() { + let shared = sharedText(item(title: "Pizza Club")) as? String + + #expect(shared == "Join Pizza Club on Flipcash and let's chat\n\n\(url.absoluteString)") + } + + @Test("A group with no name shares the bare link") + func unnamedGroupSharesTheLinkAlone() { + // Naming nothing reads worse than naming nothing explicitly: "Join on Flipcash" is a + // sentence with a hole in it, so the invitation is dropped whole. Android's rule too. + #expect(sharedText(item(title: nil)) as? String == url.absoluteString) + #expect(sharedText(item(title: "")) as? String == url.absoluteString) + #expect(sharedText(item(title: " ")) as? String == url.absoluteString) + } + + @Test("Surrounding whitespace doesn't reach the message") + func titleIsTrimmed() { + let shared = sharedText(item(title: " Pizza Club\n")) as? String + + #expect(shared == "Join Pizza Club on Flipcash and let's chat\n\n\(url.absoluteString)") + } + + @Test("AirDrop and Slack receive the URL itself, not the sentence") + func linkRenderingDestinationsGetTheBareURL() { + // Both draw the link themselves; handed text they would print the sentence beside it. + let item = item(title: "Pizza Club") + + #expect(sharedText(item, to: .airDrop) as? URL == url) + #expect(sharedText(item, to: .init(rawValue: "com.tinyspeck.chatlyio.share")) as? URL == url) + } + + @Test("The invitation is the mail subject, and empty without a name") + func subjectMatchesTheInvitation() { + let controller = UIActivityViewController(activityItems: [], applicationActivities: nil) + + #expect(item(title: "Pizza Club").activityViewController(controller, subjectForActivityType: nil) + == "Join Pizza Club on Flipcash and let's chat") + #expect(item(title: nil).activityViewController(controller, subjectForActivityType: nil) == "") + } +} From d80ffe79d0cd8181b06e1efc29b6e3993bc025eb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 15:39:13 -0400 Subject: [PATCH 13/14] feat(chat): name the group on the invite share sheet's own card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card at the top of the share sheet repeated the whole invitation sentence, which the sender is already about to send, and carried no icon. Neither reaches the recipient — `LPLinkMetadata` paints the sheet's header only — so the card's job is to confirm which group is being handed out. Title it with the group's name, and use the group's avatar as the icon when it has one. The sheet loads the avatar itself rather than relying on the screen that presented it; `ProfileAvatarStore.load` returns without a round trip once the bytes are cached. An untitled group keeps the "Invite to Join Group" fallback, and one with no picture shows the name alone. --- .../Tips/New Group/GroupInviteSheet.swift | 26 ++++++++++++++-- Flipcash/Share/GroupInviteShareItem.swift | 31 +++++++++++++------ .../Chat/GroupInviteShareItemTests.swift | 25 +++++++++++++-- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift index 025065158..28666e135 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift @@ -19,6 +19,7 @@ struct GroupInviteSheet: View { @Binding var isPresented: Bool @Environment(ConversationController.self) private var conversationController + @Environment(SessionContainer.self) private var sessionContainer /// How long the copy row stays on its checkmark before reverting — the beat the tip-card link /// row holds. @@ -37,7 +38,22 @@ struct GroupInviteSheet: View { /// would be worse than one invited to with no name at all. Nil shares the bare link — see /// ``GroupInviteShareItem``. private var title: String? { - conversationController.conversation(withID: conversationID)?.title + conversation?.title + } + + private var conversation: Conversation? { + conversationController.conversation(withID: conversationID) + } + + /// The group's picture for the share sheet's own preview card. + /// + /// Decoded at the tap rather than in a computed property, so a thumbnail isn't re-decoded on + /// every render of a sheet that shows it nowhere. Nil while the avatar hasn't loaded or the + /// group has none — the card then carries the name alone. + private func icon() -> UIImage? { + sessionContainer.profileAvatars + .data(for: .chat(conversationID)) + .flatMap(UIImage.init(data:)) } var body: some View { @@ -52,7 +68,7 @@ struct GroupInviteSheet: View { accessibilityIdentifier: "group-invite-send" ) { ShareSheet.present( - activityItem: GroupInviteShareItem(url: url, title: title) + activityItem: GroupInviteShareItem(url: url, title: title, icon: icon()) ) { _ in } } @@ -68,6 +84,12 @@ struct GroupInviteSheet: View { .padding(.top, 20) .padding(.bottom, bottomPadding) } + .task { + // Idempotent: the store returns without a round trip once the bytes are in memory or + // on disk. Loading here rather than relying on the presenting screen keeps the preview + // card's icon independent of which screen opened the sheet. + await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) + } } /// The sheet's own title bar. ``PartialSheet`` takes its height from the content it wraps, so diff --git a/Flipcash/Share/GroupInviteShareItem.swift b/Flipcash/Share/GroupInviteShareItem.swift index 56198f555..f17424fba 100644 --- a/Flipcash/Share/GroupInviteShareItem.swift +++ b/Flipcash/Share/GroupInviteShareItem.swift @@ -21,6 +21,8 @@ final class GroupInviteShareItem: NSObject, UIActivityItemSource { private let content: String private let invitation: String? + private let name: String? + private let icon: UIImage? // MARK: - Init - @@ -29,18 +31,24 @@ final class GroupInviteShareItem: NSObject, UIActivityItemSource { /// - title: the group's name, or nil while it has none. Without one there is nothing to name, /// so the share falls back to the bare link rather than a sentence with a hole in it — /// the rule Android's invite follows. - init(url: URL, title: String?) { - self.url = url - - let invitation: String? - if let name = title?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { - invitation = "Join \(name) on Flipcash and let's chat" + /// - icon: the group's picture, for the share sheet's own preview card. Nil when the group + /// has none or its avatar hasn't loaded; the card then carries the name alone. + init(url: URL, title: String?, icon: UIImage? = nil) { + self.url = url + self.icon = icon + + let name: String? + if let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + name = trimmed } else { - invitation = nil + name = nil } + let invitation = name.map { "Join \($0) on Flipcash and let's chat" } + + self.name = name self.invitation = invitation - self.content = invitation.map { "\($0)\n\n\(url.absoluteString)" } ?? url.absoluteString + self.content = invitation.map { "\($0)\n\n\(url.absoluteString)" } ?? url.absoluteString super.init() } @@ -75,11 +83,16 @@ final class GroupInviteShareItem: NSObject, UIActivityItemSource { } } + /// The card at the top of the share sheet, which the sender sees and the recipient never does. + /// + /// It names the group rather than repeating the invitation: the sentence already sits in the + /// message below, and the card's job is to confirm *which* group is about to be handed out. func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? { let metadata = LPLinkMetadata() metadata.originalURL = url metadata.url = url - metadata.title = invitation ?? "Invite to Join Group" + metadata.title = name ?? "Invite to Join Group" + metadata.iconProvider = icon.map { NSItemProvider(object: $0) } return metadata } } diff --git a/FlipcashTests/Chat/GroupInviteShareItemTests.swift b/FlipcashTests/Chat/GroupInviteShareItemTests.swift index c237abf62..34905accc 100644 --- a/FlipcashTests/Chat/GroupInviteShareItemTests.swift +++ b/FlipcashTests/Chat/GroupInviteShareItemTests.swift @@ -5,6 +5,7 @@ import Testing import UIKit +import LinkPresentation import Foundation @testable import Flipcash @@ -16,8 +17,12 @@ struct GroupInviteShareItemTests { private let url = URL(string: "https://app.flipcash.com/chat/6f9619ff-8b86-d011-b42d-00c04fc964ff")! - private func item(title: String?) -> GroupInviteShareItem { - GroupInviteShareItem(url: url, title: title) + private func item(title: String?, icon: UIImage? = nil) -> GroupInviteShareItem { + GroupInviteShareItem(url: url, title: title, icon: icon) + } + + private func metadata(_ item: GroupInviteShareItem) -> LPLinkMetadata? { + item.activityViewControllerLinkMetadata(UIActivityViewController(activityItems: [item], applicationActivities: nil)) } private func sharedText(_ item: GroupInviteShareItem, to activityType: UIActivity.ActivityType? = nil) -> Any? { @@ -64,4 +69,20 @@ struct GroupInviteShareItemTests { == "Join Pizza Club on Flipcash and let's chat") #expect(item(title: nil).activityViewController(controller, subjectForActivityType: nil) == "") } + + @Test("The share sheet's own card names the group, without the sentence") + func metadataTitleIsTheGroupName() { + // The invitation already sits in the message below; the card's job is to say which group + // is about to be handed out. + #expect(metadata(item(title: " Pizza Club\n"))?.title == "Pizza Club") + #expect(metadata(item(title: nil))?.title == "Invite to Join Group") + } + + @Test("The group's picture becomes the card's icon, and nothing stands in when there is none") + func metadataCarriesTheGroupIcon() { + let icon = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } + + #expect(metadata(item(title: "Pizza Club", icon: icon))?.iconProvider != nil) + #expect(metadata(item(title: "Pizza Club"))?.iconProvider == nil) + } } From bdcfb70bac69a8f142a40d4d9d6e0febd06f7d21 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 15:49:09 -0400 Subject: [PATCH 14/14] fix(ui): draw the at icon at full opacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IconAt.svg` carried `stroke-opacity="0.5"`, and template rendering keeps a glyph's alpha, so the @ came out half-strength against the full-white label beside it on both rows that use it — "Send Invite Link" in `GroupInviteSheet` and "Find by Username" in `NewChatScreen`. Drop the attribute. `ChatActionRow` already tints the glyph `textMain`, so any dimming belongs at the call site, not baked into the asset. --- .../Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg index 30dd476f7..07dab5e37 100644 --- a/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg +++ b/FlipcashUI/Sources/FlipcashUI/Assets/UI.xcassets/icons/IconAt.imageset/IconAt.svg @@ -1,5 +1,5 @@ - +