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..7316a99a9 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 @@ -68,8 +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. - let gateSymbol: String? + /// 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 + /// 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. @@ -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 @@ -201,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 a1c4dc60c..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. @@ -75,16 +76,24 @@ 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, + mintName: gateMintName, onAddFunds: onGateAddFunds, onJoin: onGateJoin, 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..14a57ba42 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -197,9 +197,26 @@ private func verdict(for unmet: [ConversationGateRequirement]) -> ConversationGa enum ConversationGatePresentation: Equatable { /// Nothing gated — the ordinary composer. case open - /// Rules met but not yet a member: a readable transcript with Join Chat in + /// 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 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). @@ -210,21 +227,37 @@ 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. + /// 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: return true + case .open, .readOnly: return false + case .join, .blocked, .undetermined: return true + } + } + + /// 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 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, .readOnly, .undetermined: return false + case .join, .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 } } } @@ -241,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/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index 8aa2daa23..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. @@ -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 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 mintName else { return formatted } + return "\(formatted) of \(mintName)" + } + @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,13 +129,13 @@ 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 symbol else { return "Buy More" } - return "Buy More $\(symbol)" + guard let mint, mint != .usdf else { return "Add Cash" } + 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 3d58e7e78..c087fd93a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -70,12 +70,15 @@ 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 /// 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 @@ -232,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. @@ -328,19 +351,34 @@ 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 /// 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] } - let holding = symbol.map { "\(amount.formattedDroppingZeroFraction()) of $\($0)" } + case .minimumBalance(let amount, let mint): + // A dollar-token rule is already fully stated by its dollar amount; naming the token + // as well says the same thing twice. Any other token genuinely needs naming, because + // the same $100 is a different quantity of each. + 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: @@ -354,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) @@ -378,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 { @@ -415,6 +453,7 @@ struct ConversationScreen: View { onLinkCardTap: openLinkCard, onContactAction: openContactCard, onProfileTap: profileTapAction, + onGroupInvite: { isInviting = true }, onAuthorTap: openAuthorProfile, onMessageAction: handleMessageAction, onQuoteTap: jumpToQuote, @@ -431,7 +470,12 @@ struct ConversationScreen: View { isTipDm: tipCounterpart != nil, startChattingFee: startChattingFee, gate: gate, - gateSymbol: gateSymbol, + // 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 — + // 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), + gateMintName: gateMintName, onGateAddFunds: addFunds, onGateJoin: joinChat, isJoiningChat: isJoiningChat, @@ -492,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. @@ -545,6 +589,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 @@ -798,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/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..28666e135 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/GroupInviteSheet.swift @@ -0,0 +1,131 @@ +// +// 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 + + @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. + private static let confirmationDuration: Duration = .seconds(1.5) + + @State private var didCopy = false + + private var url: URL { + .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? { + 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 { + PartialSheet(background: .backgroundMain) { + VStack(spacing: 20) { + header + + VStack(spacing: 12) { + ChatActionRow( + icon: .asset(.at), + title: "Send Invite Link", + accessibilityIdentifier: "group-invite-send" + ) { + ShareSheet.present( + activityItem: GroupInviteShareItem(url: url, title: title, icon: icon()) + ) { _ in } + } + + ChatActionRow( + icon: didCopy ? .system(.circleCheck) : .asset(.squareBehindSquare), + title: didCopy ? "Copied" : "Copy Invite Link", + accessibilityIdentifier: "group-invite-copy", + action: copy + ) + } + } + .padding(.horizontal, 16) + .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 + /// 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)) { + 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..6fa97ccae --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/MinimumBalanceAmountSheet.swift @@ -0,0 +1,107 @@ +// +// MinimumBalanceAmountSheet.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The custom balance requirement behind the form's `…` preset (node 10127:118014). +/// +/// 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 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 + + @Environment(RatesController.self) private var ratesController + + @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) { + 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) + } + .ignoresSafeArea(.keyboard) + .navigationTitle("Balance Requirement") + .toolbarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CloseButton(binding: $isPresented) + } + } + } + .onAppear(perform: seed) + } + + /// 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, + 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/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..ce0012054 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -0,0 +1,450 @@ +// +// 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()) + } + } + + // 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: { + if let customAmount { + Text(customAmount.formattedDroppingZeroFraction()) + } else { + Image.system(.ellipsis) + } + } + .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 + + 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 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 { + 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) + // 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) + .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..e4e2d366e 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(AppRouter.self) private var router + + @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,85 @@ 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 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) + router.popToRoot() + } 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/Share/GroupInviteShareItem.swift b/Flipcash/Share/GroupInviteShareItem.swift new file mode 100644 index 000000000..f17424fba --- /dev/null +++ b/Flipcash/Share/GroupInviteShareItem.swift @@ -0,0 +1,98 @@ +// +// 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? + private let name: String? + private let icon: UIImage? + + // 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. + /// - 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 { + 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 + + 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 + } + } + + /// 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 = name ?? "Invite to Join Group" + metadata.iconProvider = icon.map { NSItemProvider(object: $0) } + return metadata + } +} 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 } } } 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..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") @@ -176,6 +217,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/GroupInviteShareItemTests.swift b/FlipcashTests/Chat/GroupInviteShareItemTests.swift new file mode 100644 index 000000000..34905accc --- /dev/null +++ b/FlipcashTests/Chat/GroupInviteShareItemTests.swift @@ -0,0 +1,88 @@ +// +// GroupInviteShareItemTests.swift +// FlipcashTests +// + +import Testing +import UIKit +import LinkPresentation +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?, 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? { + 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) == "") + } + + @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) + } +} diff --git a/FlipcashTests/Chat/GroupPushRoutingTests.swift b/FlipcashTests/Chat/GroupPushRoutingTests.swift new file mode 100644 index 000000000..7730eef49 --- /dev/null +++ b/FlipcashTests/Chat/GroupPushRoutingTests.swift @@ -0,0 +1,156 @@ +// +// 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 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) + #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/ConversationGateTests.swift b/FlipcashTests/ConversationGateTests.swift index 33ef98589..eba142347 100644 --- a/FlipcashTests/ConversationGateTests.swift +++ b/FlipcashTests/ConversationGateTests.swift @@ -268,12 +268,47 @@ struct ConversationGateTests { #expect(presentation.replacesComposer) } - @Test("Only a failing listener gate blurs and freezes the transcript") - 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") + func presentation_undetermined_coversEverything() { + let gate = ConversationGatePresentation.undetermined + #expect(gate.obscuresTranscript) + #expect(gate.replacesComposer) + } + + @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.readOnly(.staff).withholdsTranscript == false) } @Test("The stated requirement is the chat's own rule, not something the user is short of") 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..07dab5e37 --- /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/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/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..2edf1306d --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/GatePreviewPlaceholder.swift @@ -0,0 +1,104 @@ +// +// 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. + /// + /// 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 = 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 + 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/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 7bcfb58fd..bc9f6135f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/TranscriptBlur.swift @@ -17,44 +17,87 @@ 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 { - /// 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)) + 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? - /// 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. + /// 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. 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 + + 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 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"