From a47e980a73618d15056dfb7ba695e4d868be79f3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:15:28 -0400 Subject: [PATCH 1/4] feat(chat): open group invite links in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route /chat/invite#s=… ahead of /chat/{uuid} and read the fragment as the invite secret, whose description is redacted. A link with no usable secret routes to the not-found state instead of the browser. The invite preview (3a) resolves the link with GetInvite and GetRoster, joins with the invite token and sealed secret, and sends a member straight to the chat. --- .../Controllers/ConversationController.swift | 11 +- .../Deep Links/DeepLinkController.swift | 19 ++ .../Core/Controllers/Deep Links/Route.swift | 24 +- .../Controllers/FlipClient+Protocols.swift | 2 + .../Navigation/AppRouter+Destination.swift | 8 +- .../AppRouter+DestinationView.swift | 3 + .../Invite Links/GroupInviteLanding.swift | 175 +++++++++++ .../Invite Links/GroupInviteScreen.swift | 295 ++++++++++++++++++ .../Core/Screens/Main/ScanViewModel.swift | 3 + .../Conversation/InviteLinkSecret.swift | 63 ++++ .../Chat/GroupInviteLandingTests.swift | 63 ++++ FlipcashTests/RouteTests.swift | 40 +++ .../TestSupport/MockConversations.swift | 4 + 13 files changed, 706 insertions(+), 4 deletions(-) create mode 100644 Flipcash/Core/Screens/Invite Links/GroupInviteLanding.swift create mode 100644 Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/InviteLinkSecret.swift create mode 100644 FlipcashTests/Chat/GroupInviteLandingTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index d50c772ba..8cdd8b0c9 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1207,7 +1207,16 @@ final class ConversationController { /// Joins a group and seats the metadata the join returns. Throws so the caller can surface the /// failure; nothing local moves unless the server accepted the join. func join(conversationID: ConversationID) async throws { - let conversation = try await membership.joinChat(owner: owner, conversationID: conversationID) + seatJoined(try await membership.joinChat(owner: owner, conversationID: conversationID), in: conversationID) + } + + /// Joins a private group through an invite link. Throws ``ErrorJoinChat`` as the server + /// answered; the group is seated only once the join is accepted. + func join(conversationID: ConversationID, invite: InviteJoinParameters) async throws { + seatJoined(try await membership.joinChat(owner: owner, conversationID: conversationID, invite: invite), in: conversationID) + } + + private func seatJoined(_ conversation: Conversation, in conversationID: ConversationID) { store.apply(.metadataRefresh(conversation)) store.setMembership(true, in: conversationID) persistConversation(conversation) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 623a63a3c..3c3ed72c9 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -171,6 +171,12 @@ final class DeepLinkController { case .chatMessage(let conversationID, let messageID): return action(.chatMessage(conversationID, messageID)) + case .groupInvite(let secret): + return action(.groupInvite(secret)) + + case .invalidGroupInvite: + return action(.groupInvite(nil)) + case .tip(let userID): return action(.tip(userID)) @@ -460,6 +466,15 @@ struct DeepLinkAction { ) } + case .groupInvite(let secret): + if let container = sessionAuthenticator.loggedInContainer { + Analytics.deeplinkRouted(kind: kind) + if let source = origin.groupInviteSource { + Analytics.groupInviteFollowed(source: source) + } + container.appRouter.navigate(to: .groupInvite(secret)) + } + case .chatSendCash(let conversationID): if let container = sessionAuthenticator.loggedInContainer { // Only tip DMs take the Send Cash action; contact/phone sends are no longer @@ -538,6 +553,9 @@ extension DeepLinkAction { case chatSendCash(ConversationID) /// One message in a chat: the chat, opened on that message. case chatMessage(ConversationID, MessageID) + /// The preview of the group an invite link admits to; nil when the link holds no usable + /// secret, which shows as a link that matches no invite. + case groupInvite(InviteLinkSecret?) /// The tipcard for the legacy `/tip/` link. case tip(UserID) /// The profile a `flipcash.com/` or `flipcash.com/` link names. @@ -560,6 +578,7 @@ extension DeepLinkAction.Kind { case .chat: "Chat" case .chatSendCash: "ChatSendCash" case .chatMessage: "ChatMessage" + case .groupInvite: "GroupInvite" case .tip: "Tip" case .profile: "Profile" case .wallet: "Wallet" diff --git a/Flipcash/Core/Controllers/Deep Links/Route.swift b/Flipcash/Core/Controllers/Deep Links/Route.swift index a4388ddd0..5b5dc20f0 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -32,9 +32,21 @@ nonisolated struct Route { normalizedPath = components.path } - guard let path = Path.parse(path: normalizedPath, scheme: components.scheme) else { + guard var path = Path.parse(path: normalizedPath, scheme: components.scheme) else { return nil } + + // An invite link's fragment is its secret, not a set of route fragments: it is read here + // and nowhere else, and the link carries no properties to keep. + if case .invalidGroupInvite = path { + if let fragment = url.fragment(percentEncoded: true), let secret = InviteLinkSecret(fragment: fragment) { + path = .groupInvite(secret) + } + self.path = path + self.properties = [:] + self.fragments = [:] + return + } // 1. Parse any query items into properties @@ -148,6 +160,10 @@ nonisolated extension Route { /// One message in a chat — `/chat/{chatId}/m/{seq}`, where `seq` is the message's /// per-chat id. Opens the chat on that message. case chatMessage(ConversationID, MessageID) + /// A group invite link — `/chat/invite#s=`. Opens the invite preview. + case groupInvite(InviteLinkSecret) + /// `/chat/invite` whose fragment holds no usable secret: a broken link, shown as one. + case invalidGroupInvite /// The older tipcard link, `/tip/`, which stays parseable for /// links already shared and still opens the tipcard. case tip(UserID) @@ -205,6 +221,12 @@ nonisolated extension Route { guard components.count > 1 else { return nil } + // Ahead of the chat id: `/chat/invite` holds a secret in its fragment and never + // falls through to a chat, or to the browser, whether or not the secret parses. + // ``Route/init(url:)`` reads the fragment. + if components.count == 2, components[1].lowercased() == "invite" { + return .invalidGroupInvite + } // 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. diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index cffb0a183..52c300595 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -79,6 +79,8 @@ protocol MentionSuggestionFetching: AnyObject, Sendable { protocol ConversationMembership: AnyObject, Sendable { /// Joins a chat, returning its metadata as it stands after the join. func joinChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation + /// Joins a private group through an invite link. + func joinChat(owner: KeyPair, conversationID: ConversationID, invite: InviteJoinParameters) async throws -> Conversation func leaveChat(owner: KeyPair, conversationID: ConversationID) async throws } diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 76b14e9fd..ed419f5f7 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -157,6 +157,9 @@ extension AppRouter { case inviteLinks(ConversationID) /// Every member of a private group, pushed from the count on its profile's Members grid. case groupMembers(ConversationID) + /// The group an invite link admits to, previewed before joining; nil for a link that holds + /// no usable secret. The secret prints redacted, and ``payload`` never carries it. + case groupInvite(InviteLinkSecret?) /// The stack this destination naturally belongs in. Cross-stack /// navigation uses this to know which sheet to present, or which tab @@ -182,7 +185,7 @@ extension AppRouter { case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .editGroupCover, - .editGroupDescription, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers: + .editGroupDescription, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers, .groupInvite: return .tips } } @@ -254,6 +257,7 @@ extension AppRouter { case .joinRequests: "joinRequests" case .inviteLinks: "inviteLinks" case .groupMembers: "groupMembers" + case .groupInvite: "groupInvite" } } @@ -305,7 +309,7 @@ extension AppRouter { .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw, .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, - .archivedChats, .chatSearch, .requests: + .archivedChats, .chatSearch, .requests, .groupInvite: return nil } } diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 7193883db..91f1000e5 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -255,6 +255,9 @@ struct DestinationView: View { GroupMembersScreen(conversationID: conversationID) .id(conversationID) + case .groupInvite(let secret): + GroupInviteScreen(secret: secret) + case .tipConversation(let conversationID): // `.id` forces fresh view identity per conversation. ConversationScreen(context: .existing(conversationID)) diff --git a/Flipcash/Core/Screens/Invite Links/GroupInviteLanding.swift b/Flipcash/Core/Screens/Invite Links/GroupInviteLanding.swift new file mode 100644 index 000000000..0991b3cd1 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteLanding.swift @@ -0,0 +1,175 @@ +// +// GroupInviteLanding.swift +// Code +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +private let logger = Logger(label: "flipcash.group-invite") + +/// The joiner's side of an invite link: derive the token, look the link up, read the preview +/// roster with it, and join. Mirrors Android's `GroupInviteLanding`. The secret is held by the +/// screen for its lifetime and never logged; only the chat id appears in a log line. +struct GroupInviteLanding { + + /// Why a link no longer admits anyone. Each has its own line on the dead-invite screen + /// (node 12846:126). + enum DeadReason: Equatable { + case exhausted + case expired + case revoked + } + + /// What opening an invite link found. + enum Lookup { + /// The viewer is already in the group, so the link has nothing to offer: open the chat. + case alreadyMember(ConversationID) + /// The group as the link shows it. `dead` is set when the link no longer admits anyone. + case found(token: InviteToken, conversation: Conversation, dead: DeadReason?) + /// The link matches no invite (node 12846:148). + case notFound + /// The host removed the viewer from this group (node 12846:160). + case banned + /// The lookup itself failed; nothing is known about the link. + case failed + } + + /// What joining through a link came to. + enum JoinResult: Equatable { + case joined(ConversationID) + /// The link died between preview and join; the screen switches to the dead state in place. + case dead(DeadReason) + /// INVITE_NOT_FOUND: the link matches no invite. + case notFound + /// NOT_FOUND: the link is real but the group is gone. + case groupGone + case banned + /// DENIED or RULES_NOT_SATISFIED. Neither should happen with an invite, so the screen + /// stays on the preview with the generic alert rather than the connection one. + case refused + /// A transport failure: the connection alert (node 12846:220). + case failed + + /// The alert a failed join shows, or nil when the screen changes state instead. + /// BANNED and INVITE_NOT_FOUND pop the preview once dismissed, as the spec's JoinChat table says. + var alert: JoinAlert? { + switch self { + case .banned: + JoinAlert(title: "You Can’t Join This Group", subtitle: "The host has removed you from this group", popsPreview: true) + case .notFound: + JoinAlert(title: "Link Not Found", subtitle: "This invite link isn’t valid. Ask whoever sent it for a new one", popsPreview: true) + case .refused: + JoinAlert(title: "Couldn’t Join Group", subtitle: "Something went wrong. Please try again", popsPreview: false) + case .failed: + JoinAlert(title: "Couldn’t Join Group", subtitle: "Check your connection and try again", popsPreview: false) + case .joined, .dead, .groupGone: + nil + } + } + } + + struct JoinAlert: Equatable { + let title: String + let subtitle: String + let popsPreview: Bool + } + + /// One read of the preview roster, or the news that the link stopped granting it. + enum RosterResult { + case page([ConversationMember], memberCount: UInt64) + /// `GetRoster` answered DENIED: the invite is no longer valid. + case denied + case failed + } + + struct Service { + typealias GetInvite = @MainActor @Sendable (InviteToken) async throws -> (conversation: Conversation, status: Invite.Status) + typealias GetRoster = @MainActor @Sendable (ConversationID, InviteToken) async throws -> FlipClient.RosterPage + typealias Join = @MainActor @Sendable (ConversationID, InviteJoinParameters) async throws -> Void + typealias IsMember = @MainActor @Sendable (ConversationID) -> Bool + + let owner: KeyPair + let getInvite: GetInvite + let getRoster: GetRoster + let join: Join + let isMember: IsMember + } + + let service: Service + + @MainActor + func lookup(_ secret: InviteLinkSecret) async -> Lookup { + guard let token = try? secret.token() else { return .notFound } + let preview: (conversation: Conversation, status: Invite.Status) + do { + preview = try await service.getInvite(token) + } catch ErrorGetInvite.notFound { + return .notFound + } catch ErrorGetInvite.banned { + return .banned + } catch { + return .failed + } + if service.isMember(preview.conversation.id) { + return .alreadyMember(preview.conversation.id) + } + return .found(token: token, conversation: preview.conversation, dead: Self.deadReason(preview.status)) + } + + /// The invite's own token reads the roster of a group the viewer isn't in. + @MainActor + func roster(of conversationID: ConversationID, token: InviteToken) async -> RosterResult { + do { + let page = try await service.getRoster(conversationID, token) + return .page(page.members, memberCount: page.rosterSummary.memberCount) + } catch ErrorGetRoster.denied { + return .denied + } catch { + return .failed + } + } + + @MainActor + func join(_ secret: InviteLinkSecret, conversationID: ConversationID) async -> JoinResult { + guard let parameters = try? secret.joinParameters(owner: service.owner, conversationID: conversationID) else { + return .failed + } + do { + try await service.join(conversationID, parameters) + return .joined(conversationID) + } catch let error as ErrorJoinChat { + switch error { + case .inviteExhausted: return .dead(.exhausted) + case .inviteExpired: return .dead(.expired) + case .inviteRevoked: return .dead(.revoked) + case .inviteNotFound: return .notFound + case .notFound: return .groupGone + case .banned: return .banned + case .denied, .rulesNotSatisfied: + // Points to a client bug (see `refused`). The chat id is the only identifier + // logged, never the link or its secret. + logger.error("JoinChat refused an invite join", metadata: [ + "error": "\(error)", + "conversationID": "\(conversationID)", + ]) + return .refused + case .ok, .unknown, .transportFailure, .cancelled, .rejected: + return .failed + } + } catch { + return .failed + } + } + + static func deadReason(_ status: Invite.Status) -> DeadReason? { + switch status { + case .exhausted: .exhausted + case .expired: .expired + case .revoked: .revoked + case .active, .unknown: nil + } + } +} diff --git a/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift b/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift new file mode 100644 index 000000000..8878ba16c --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift @@ -0,0 +1,295 @@ +// +// GroupInviteScreen.swift +// Code +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// What `/chat/invite#s=…` opens (nodes 12846:59, 12846:126, 12846:148, 12846:160). A member goes +/// straight to the chat; anyone else sees the group and joins with the link or, once the link is +/// dead, asks the host to let them in. Mirrors Android's `GroupInviteScreen`. +/// +/// The profile's own pieces draw the preview: ``ChatProfileScreen`` is bound to a chat the viewer +/// is in, and this one isn't. +struct GroupInviteScreen: View { + + /// Held for the screen's life only; nil for a link whose fragment holds none. + let secret: InviteLinkSecret? + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ConversationController.self) private var conversationController + @Environment(AppRouter.self) private var router + + @State private var phase: Phase = .loading + @State private var token: InviteToken? + @State private var members: [ConversationMember] = [] + @State private var memberCount: UInt64 = 0 + @State private var isJoining = false + @State private var isRequesting = false + @State private var dialogItem: DialogItem? + + enum Phase { + case loading + case preview(Conversation, dead: Bool, reason: GroupInviteLanding.DeadReason?) + case notFound + case groupGone + case banned + case loadFailed + } + + private var landing: GroupInviteLanding { sessionContainer.inviteLanding } + + var body: some View { + content + .dialog(item: $dialogItem) + .task { await open() } + } + + @ViewBuilder + private var content: some View { + switch phase { + case .loading: + Background(color: .backgroundMain) { + ProgressView() + .tint(Color.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + case .preview(let conversation, let dead, let reason): + preview(conversation, dead: dead, reason: reason) + case .notFound: + notice(title: "Link Not Found", body: "This invite link isn’t valid. Ask whoever sent it for a new one.") + case .groupGone: + notice(title: "Group Not Found", body: "This group no longer exists") + case .banned: + notice(title: "You Can’t Join This Group", body: "The host has removed you from this group.") + case .loadFailed: + notice(title: "Couldn’t Join Group", body: "Check your connection and try again.") + } + } + + // MARK: - Preview - + + private func preview(_ conversation: Conversation, dead: Bool, reason: GroupInviteLanding.DeadReason?) -> some View { + let id = conversation.id + let title = conversationController.displayName(for: conversation) + return Background(color: .backgroundMain) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ProfileHeaderView( + cover: .group(id, picture: conversation.coverPicture), + title: title, + subtitle: nil, + bodyText: conversation.description, + avatar: { + ProfileHeaderAvatar( + id: id.description, + displayName: title, + imageData: sessionContainer.profileAvatars.data(for: .chat(id)), + blurhash: conversation.picture?.thumbnailBlurhash + ) + }, + bannerControls: { EmptyView() }, + coverAccessory: { EmptyView() }, + rowActions: { EmptyView() }, + underSubtitle: { EmptyView() } + ) + + // A dead link whose reason isn't known gets no line rather than a guessed one. + if let note = dead ? reason.map(Self.deadLine) : "Private group · You’ve been invited to join" { + Label { + Text(note) + } icon: { + Image(systemName: "lock.fill") + } + .labelStyle(.titleAndIcon) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, ProfileHeaderMetrics.inset) + .padding(.top, 16) + .accessibilityIdentifier("group-invite-note") + } + + let portraits = members.compactMap { GroupPortrait($0, creator: conversation.creator) } + GroupChattingGrid( + title: PrivateGroupCopy.memberCount(memberCount), + portraits: Array(portraits.prefix(GroupChattingGrid.memberLimit)) + ) { _ in } + .padding(.top, 24) + } + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + .ignoresSafeArea(edges: .top) + } + .scrollEdgeBar(.bottom) { + Group { + if dead { + requestActions(id) + } else { + Button { + Task { await join(id) } + } label: { + ButtonStateLabel("Join Group", state: isJoining ? .loading : .normal) + } + .buttonStyle(.filled) + .disabled(isJoining) + .accessibilityIdentifier("group-invite-join") + } + } + .padding(.horizontal, ProfileHeaderMetrics.inset) + .padding(.top, 12) + .padding(.bottom, 8) + } + .task(id: id) { + await sessionContainer.profileAvatars.load(.chat(id), picture: conversation.picture) + } + } + + /// A dead link can still lead in: the host approves a request, as on the group's profile. + @ViewBuilder + private func requestActions(_ id: ConversationID) -> some View { + if let requestedAt = sessionContainer.lobbyRequests.requestedAt(id) { + TimelineView(.everyMinute) { context in + Button(PrivateGroupCopy.pendingTitle(requestedAt: requestedAt, now: context.date)) {} + .buttonStyle(.filled) + .disabled(true) + } + } else { + Button { + Task { await requestToJoin(id) } + } label: { + ButtonStateLabel("Request to Join", state: isRequesting ? .loading : .normal) + } + .buttonStyle(.filled) + .disabled(isRequesting) + .accessibilityIdentifier("group-invite-request") + } + } + + /// The line that stands where "You've been invited to join" does. + static func deadLine(_ reason: GroupInviteLanding.DeadReason) -> String { + switch reason { + case .exhausted: "This invite link has reached its limit." + case .expired: "This invite link has expired." + case .revoked: "This invite link was turned off by the host." + } + } + + // MARK: - Notice - + + /// Nodes 12846:148 and 12846:160: a title, a line, and OK, centred. + private func notice(title: String, body: String) -> some View { + Background(color: .backgroundMain) { + VStack(spacing: 16) { + Text(title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Text(body) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + Button("OK") { router.popTopmost() } + .buttonStyle(.subtle) + .padding(.top, 8) + } + .multilineTextAlignment(.center) + .padding(.horizontal, ProfileHeaderMetrics.inset * 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("group-invite-notice") + } + } + + // MARK: - Actions - + + private func open() async { + guard case .loading = phase else { return } + guard let secret else { + phase = .notFound + return + } + switch await landing.lookup(secret) { + case .alreadyMember(let id): + openChat(id) + case .notFound: + phase = .notFound + case .banned: + phase = .banned + case .failed: + phase = .loadFailed + case .found(let token, let conversation, let dead): + self.token = token + memberCount = conversation.rosterSummary.memberCount + phase = .preview(conversation, dead: dead != nil, reason: dead) + // A dead link's roster is refused, so only a live one is asked for. + if dead == nil { + await loadRoster(conversation, token: token) + } + } + } + + private func loadRoster(_ conversation: Conversation, token: InviteToken) async { + switch await landing.roster(of: conversation.id, token: token) { + case .page(let page, let count): + members = page + memberCount = count + case .denied: + // The link stopped admitting people while the roster was read. The real status is + // asked for again; when it can't be learned, the line is left out rather than guessed. + var reason: GroupInviteLanding.DeadReason? + if let secret, case .found(_, _, let dead) = await landing.lookup(secret) { + reason = dead + } + phase = .preview(conversation, dead: true, reason: reason) + case .failed: + break + } + } + + private func join(_ id: ConversationID) async { + guard let secret, !isJoining, case .preview(let conversation, _, _) = phase else { return } + isJoining = true + defer { isJoining = false } + let result = await landing.join(secret, conversationID: id) + if let alert = result.alert { + let item = DialogItem.error(title: alert.title, subtitle: alert.subtitle) + dialogItem = alert.popsPreview ? item.onDismiss { router.popTopmost() } : item + return + } + switch result { + case .joined(let id): + openChat(id) + case .dead(let reason): + phase = .preview(conversation, dead: true, reason: reason) + case .groupGone: + phase = .groupGone + case .notFound, .banned, .refused, .failed: + break + } + } + + private func requestToJoin(_ id: ConversationID) async { + isRequesting = true + defer { isRequesting = false } + do { + if case .alreadyMember = try await sessionContainer.lobbyRequests.request(id) { + openChat(id) + } + } catch let error as LobbyRequests.RequestError { + dialogItem = .error(title: "Couldn’t Send Request", subtitle: error.explanation) + } catch { + dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to send your request. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to request to join a private group from an invite") + } + } + + /// Replaces this screen, so back from the chat doesn't land on the link again. + private func openChat(_ id: ConversationID) { + router.replaceTopmostAny(AppRouter.Destination.tipConversation(id)) + } +} diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index 60863db9e..3d81f0263 100644 --- a/Flipcash/Core/Screens/Main/ScanViewModel.swift +++ b/Flipcash/Core/Screens/Main/ScanViewModel.swift @@ -157,6 +157,9 @@ class ScanViewModel { // transcript, which gates the join itself. A message link lands on the same transcript. case .chat, .chatMessage: return true + // An invite link opens its preview; a broken one opens the not-found notice. + case .groupInvite, .invalidGroupInvite: + return true case .login, .verifyEmail, .chatSendCash, .give, .balance, .discover, .unknown: return false } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/InviteLinkSecret.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/InviteLinkSecret.swift new file mode 100644 index 000000000..adef1cb17 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/InviteLinkSecret.swift @@ -0,0 +1,63 @@ +// +// InviteLinkSecret.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import SharedCoreKit + +/// The 32-byte secret in an invite link's `#s=` fragment. +/// +/// The secret is the link's key material: whoever holds it can read the group's roster and join. +/// It is never printed: ``description`` and ``debugDescription`` are redacted, so a log line or a +/// navigation trace that interpolates one carries nothing. +public struct InviteLinkSecret: Hashable, Sendable, CustomStringConvertible, CustomDebugStringConvertible { + + public let data: Data + + public init(data: Data) { + self.data = data + } + + /// The secret in a URL fragment (without its `#`), or nil when it holds none. Shared core owns + /// the fragment grammar, so iOS and Android accept the same links. + public init?(fragment: String) { + guard let data = SharedInviteCipher.secret(fragment: fragment) else { return nil } + self.data = data + } + + public var description: String { "InviteLinkSecret(redacted)" } + public var debugDescription: String { description } +} + +// MARK: - Joining - + +extension InviteLinkSecret { + + /// The bearer token the link presents to `GetInvite`, `GetRoster` and `JoinChat`. + public func token() throws -> InviteToken { + InviteToken(data: try SharedInviteCipher.inviteToken(secret: data)) + } + + /// What `JoinChat` takes to join through this link: the token, and the secret sealed to the + /// joiner so that the invite's key bundle opens for them afterwards. + public func joinParameters(owner: KeyPair, conversationID: ConversationID) throws -> InviteJoinParameters { + let inviteID = try SharedInviteCipher.inviteId(secret: data) + let envelope = try SharedInviteCipher.sealSecretEnvelope( + owner: SharedEd25519.KeyPair(publicKey: Data(owner.publicKey.bytes), privateKey: Data(owner.privateKey.bytes)), + secret: data, + chatId: conversationID.data, + inviteId: inviteID + ) + return InviteJoinParameters( + token: try token(), + secretEnvelope: InviteSecretEnvelope( + scheme: SharedCoreInviteSealer.scheme, + nonce: envelope.nonce, + ciphertext: envelope.ciphertext + ) + ) + } +} diff --git a/FlipcashTests/Chat/GroupInviteLandingTests.swift b/FlipcashTests/Chat/GroupInviteLandingTests.swift new file mode 100644 index 000000000..991317000 --- /dev/null +++ b/FlipcashTests/Chat/GroupInviteLandingTests.swift @@ -0,0 +1,63 @@ +// +// GroupInviteLandingTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Joining a group through an invite link") +struct GroupInviteLandingTests { + + private let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + private let secret = InviteLinkSecret(data: Data(repeating: 9, count: 32)) + + private func landing(joinThrows error: ErrorJoinChat) -> GroupInviteLanding { + GroupInviteLanding(service: .init( + owner: KeyPair.generate()!, + getInvite: { _ in throw ErrorGetInvite.notFound }, + getRoster: { _, _ in throw ErrorGetRoster.denied }, + join: { _, _ in throw error }, + isMember: { _ in false } + )) + } + + @Test("BANNED from JoinChat shows the alert and pops the preview") + func bannedPops() async { + let result = await landing(joinThrows: .banned).join(secret, conversationID: conversationID) + #expect(result == .banned) + #expect(result.alert == .init( + title: "You Can’t Join This Group", + subtitle: "The host has removed you from this group", + popsPreview: true + )) + } + + @Test("INVITE_NOT_FOUND from JoinChat shows the alert and pops the preview") + func inviteNotFoundPops() async { + let result = await landing(joinThrows: .inviteNotFound).join(secret, conversationID: conversationID) + #expect(result == .notFound) + #expect(result.alert == .init( + title: "Link Not Found", + subtitle: "This invite link isn’t valid. Ask whoever sent it for a new one", + popsPreview: true + )) + } + + @Test("DENIED and a transport failure keep the preview") + func otherFailuresStay() async { + let denied = await landing(joinThrows: .denied).join(secret, conversationID: conversationID) + #expect(denied.alert == .init(title: "Couldn’t Join Group", subtitle: "Something went wrong. Please try again", popsPreview: false)) + let transport = await landing(joinThrows: .transportFailure).join(secret, conversationID: conversationID) + #expect(transport.alert == .init(title: "Couldn’t Join Group", subtitle: "Check your connection and try again", popsPreview: false)) + } + + @Test("A dead link or a gone group changes the screen instead of alerting") + func stateChangesHaveNoAlert() async { + #expect(await landing(joinThrows: .inviteExpired).join(secret, conversationID: conversationID).alert == nil) + #expect(await landing(joinThrows: .notFound).join(secret, conversationID: conversationID).alert == nil) + } +} diff --git a/FlipcashTests/RouteTests.swift b/FlipcashTests/RouteTests.swift index 9513dadec..f678918da 100644 --- a/FlipcashTests/RouteTests.swift +++ b/FlipcashTests/RouteTests.swift @@ -167,6 +167,46 @@ struct RouteTests { #expect(Route(url: URL(string: "https://app.flipcash.com/chat")!) == nil) } + @Test("An invite link routes to the invite, ahead of the chat id, from both URL formats") + func inviteLinkRoutesAheadOfChat() throws { + let secret = String(repeating: "A", count: 43) + for text in ["https://app.flipcash.com/chat/invite#s=\(secret)", "flipcash://chat/invite#s=\(secret)"] { + let url = try #require(URL(string: text)) + let route = try #require(Route(url: url)) + guard case .groupInvite(let parsed) = route.path else { + Issue.record("\(text) parsed as \(route.path)") + continue + } + #expect(parsed.data == Data(count: 32)) + // The secret is the fragment; nothing of it is kept as a route fragment. + #expect(route.fragments.isEmpty) + } + } + + @Test("An invite link with no usable secret is a broken invite, not a chat or a web link") + func inviteLinkWithoutSecretIsInvalid() throws { + for text in [ + "https://app.flipcash.com/chat/invite", + "https://app.flipcash.com/chat/invite#s=", + "https://app.flipcash.com/chat/invite#s=short", + "https://app.flipcash.com/chat/invite#e=\(String(repeating: "A", count: 43))", + ] { + let url = try #require(URL(string: text)) + let route = try #require(Route(url: url), "\(text)") + guard case .invalidGroupInvite = route.path else { + Issue.record("\(text) parsed as \(route.path)") + continue + } + } + } + + @Test("An invite secret never prints") + func inviteSecretIsRedacted() { + let secret = InviteLinkSecret(data: Data(repeating: 7, count: 32)) + #expect("\(secret)" == "InviteLinkSecret(redacted)") + #expect(String(reflecting: secret) == "InviteLinkSecret(redacted)") + } + @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 diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index c92d5971c..75932a2de 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -357,6 +357,10 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con return conversation } + func joinChat(owner: KeyPair, conversationID: ConversationID, invite: InviteJoinParameters) async throws -> Conversation { + try await joinChat(owner: owner, conversationID: conversationID) + } + // MARK: - ConversationMembership /// Answers with the chat as the group feed or the DM feed holds it; a join for a chat neither From d2252590121bcc79b318d5a605713aceb8ba8417 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:15:29 -0400 Subject: [PATCH 2/4] feat(chat): show an invite link as a group card The card resolves an invite link with GetInvite by its secret instead of GetChat, and shows the link's status in place of the entry rule. The resolver keys it on a digest of the link, so the secret is never a memo key. Tapping it opens the invite preview. --- .../Controllers/ConversationController.swift | 1 + .../Core/Controllers/Deep Links/Route.swift | 18 +---- .../Conversation/ConversationScreen.swift | 11 +++- .../Screens/Conversation/GroupLinkCard.swift | 15 ++++- .../Conversation/LinkCardClassifier.swift | 11 +++- .../Screens/Conversation/LinkCardFeed.swift | 2 +- .../Conversation/LinkCardResolver.swift | 63 +++++++++++++++++- .../Conversation/MessageLinkLookup.swift | 4 +- .../Core/Screens/Main/Search/ChatSearch.swift | 7 +- .../Core/Session/SessionAuthenticator.swift | 18 +++++ .../Core/Spotlight/ChatSpotlightItem.swift | 2 +- .../Models/Chat/ChatMediaStrings.swift | 2 +- .../Models/Chat/ChatPreviewMapping.swift | 2 +- .../FlipcashCore/Models/Chat/ChatQuote.swift | 2 +- .../Models/Chat/InviteLinkText.swift | 65 +++++++++++++++++++ .../FlipcashCore/Models/Chat/LinkCard.swift | 30 +++++++-- .../Push/NotificationPayload.swift | 2 +- .../InviteLinkTextTests.swift | 50 ++++++++++++++ FlipcashTests/Chat/ChatSearchTests.swift | 20 ++++++ .../Chat/LinkCardClassifierTests.swift | 24 +++++++ .../Chat/MessageLinkLookupTests.swift | 7 ++ 21 files changed, 318 insertions(+), 38 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Chat/InviteLinkText.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/InviteLinkTextTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 8cdd8b0c9..6594a6a5e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1680,6 +1680,7 @@ final class ConversationController { case .text(let text): // A bare prefix says less than no line at all, so an empty body previews as nothing. guard !text.isEmpty else { return nil } + let text = InviteLinkText.redacting(text) if isFromSelf { return "You: \(text)" } guard let senderName else { return text } return "\(senderName): \(text)" diff --git a/Flipcash/Core/Controllers/Deep Links/Route.swift b/Flipcash/Core/Controllers/Deep Links/Route.swift index 5b5dc20f0..61e47dc3d 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -97,14 +97,7 @@ nonisolated extension Route { /// A jump link carries its target percent-encoded in `#source=`. A jump pointing at another /// jump is malformed and is not followed. Mirrors Android `DeepLink.unwrapJumpTarget`. static func unwrappingJump(_ url: URL) -> URL? { - let prefix = "https://jump.flipcash.com/#source=" - guard url.absoluteString.hasPrefix(prefix) else { return nil } - let encoded = String(url.absoluteString.dropFirst(prefix.count)) - guard let decoded = encoded.removingPercentEncoding, - let target = URL(string: decoded), - !decoded.hasPrefix(prefix) - else { return nil } - return target + InviteLinkText.unwrappingJump(url) } } @@ -116,13 +109,8 @@ nonisolated extension Route { /// Android's manifest intent filters. `www.flipcash.com` is Android-only for routing and is /// here anyway: whether a link *is* a Flipcash link is not a question about which app opens /// it, and the cross-platform fixture has to agree on one answer. - static let flipcashHosts: Set = [ - "app.flipcash.com", - "send.flipcash.com", - "flipcash.com", - "www.flipcash.com", - "jump.flipcash.com", - ] + /// Kept in FlipcashCore so the preview text that hides invite links matches on the same hosts. + static let flipcashHosts: Set = InviteLinkText.flipcashHosts /// Whether `url` is one of ours: the custom scheme, or an exact match on ``flipcashHosts``. /// diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 3d518f424..ada09f6f2 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1818,17 +1818,22 @@ struct ConversationScreen: View { // or the request. The card never joins, buys or requests from here. openLink(card.url) case .group(let group): + // An invite link opens its own preview, which sends a member on to the chat. + guard let chatID = group.chatID else { + openLink(card.url) + return + } // A link to the chat already on screen has nowhere to go. - guard group.chatID != conversationID else { return } + guard chatID != conversationID else { return } Analytics.groupInviteFollowed(source: .chatCard) // The group's profile, seeded from the card's own lookup as a featured group seeds it; // its Open Chat leads on to the chat. Before the lookup answers there is nothing to // seed it with, so the tap opens the chat, which fetches the group by its id. if let conversation = sessionContainer.linkCardFeed.groupConversation(for: card) { conversationController.hold(conversation) - router.push(.chatProfile(group.chatID, origin: .link)) + router.push(.chatProfile(chatID, origin: .link)) } else { - router.push(.tipConversation(group.chatID)) + router.push(.tipConversation(chatID)) } case .token(let token): Analytics.tokenInfoOpened(from: .openedFromChat, mint: token.mint) diff --git a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift index 67bff3d24..77cd44707 100644 --- a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift +++ b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift @@ -17,6 +17,8 @@ nonisolated struct GroupLinkFacts: Sendable, Equatable { /// The name of the token ``headline(for:)`` names, or nil when the rule names none or names the /// dollar token, which the card states by amount alone. let headlineMintName: String? + /// The link's status, for an invite link's card; nil for a join link. + var inviteStatus: Invite.Status? = nil } /// Turns what a group link's lookup fetched into what its card shows. Main-actor because the @@ -63,13 +65,24 @@ func groupLinkCard(_ facts: GroupLinkFacts, imageData: Data?) -> LinkCard.Group. avatarID: conversation.id.description, imageData: imageData, blurHash: conversation.picture?.thumbnailBlurhash, - requirement: groupRequirementLine( + requirement: facts.inviteStatus.map(inviteStatusLine) ?? groupRequirementLine( headline(for: conversation.rules?.listener ?? []), mintName: facts.headlineMintName ) ) } +/// An invite card's status line, in place of the entry rule: the link admits on its own. +/// Provisional copy: no frame draws this card. +func inviteStatusLine(_ status: Invite.Status) -> String { + switch status { + case .active, .unknown: "Private group · Invite link" + case .expired: "This invite link has expired" + case .revoked: "This invite link was turned off" + case .exhausted: "This invite link has reached its limit" + } +} + extension Conversation { /// What an untitled group is called on its invite card. Provisional: no design names it yet. diff --git a/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift b/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift index 262e091cf..5081bcae6 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift @@ -26,7 +26,7 @@ import FlipcashCore /// **Route.** Whatever survives the host gate goes to the real parser. No second path parser is /// written. /// -/// Five paths become cards: `.cash`, `.token`, `.chat` (a group invite), and `.tip` / `.username` (a +/// These paths become cards: `.cash`, `.token`, `.chat` and `.groupInvite` (a group), and `.tip` / `.username` (a /// person's tip card link). The rest are refused by name rather than by a /// `default`, so a new route has to be ruled on here instead of inheriting a card. `.login` and /// `.verifyEmail` in particular carry the account seed and a verification secret, and a card with a @@ -127,6 +127,15 @@ nonisolated struct LinkCardClassifier { guard !Self.reservedPaths.contains(username.value) else { return nil } return .user(LinkCard.User(url: target, identity: .username(username), range: link.range)) + case .groupInvite: + // Resolved with `GetInvite` by the secret in the fragment, never fetched as a web page: + // the link is the group's key (Decision 6). + return .group(LinkCard.Group(inviteURL: target, range: link.range)) + + case .invalidGroupInvite: + // No secret to look up. It stays a link, and a tap still opens it in the app. + return nil + case .login, .verifyEmail, .chatSendCash, .give, .balance, .discover, .unknown: return nil diff --git a/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift b/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift index e42f54792..195544a44 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift @@ -186,7 +186,7 @@ final class LinkCardFeed: LinkCardSource { } case .group(let group): Task { [resolver] in - let facts = await resolver.group(group.chatID) + let facts = await resolver.group(group) deliverFetched(facts.map(Fetched.group), missing: .group(.unavailable), for: key) } case .user(let user): diff --git a/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift b/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift index a5efa5f39..1241c95a6 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift @@ -3,6 +3,7 @@ // Flipcash // +import CryptoKit import Foundation import FlipcashCore @@ -31,6 +32,7 @@ actor LinkCardResolver { private let cashLookup: @Sendable (String) async throws -> LinkCard.Cash.Resolved private let mintLookup: @Sendable (PublicKey) async throws -> LinkCard.Token.Resolved private let groupLookup: @Sendable (ConversationID) async throws -> GroupLinkFacts + private let inviteLookup: @Sendable (InviteLinkSecret) async throws -> GroupLinkFacts private let userLookup: @Sendable (LinkCard.User.Identity) async throws -> UserLinkFacts private let messageLookup: @Sendable (LinkCard.Message) async throws -> LinkCard.Message.State @@ -48,6 +50,9 @@ actor LinkCardResolver { private var cashQueries: [String: Task] = [:] private var tokenQueries: [PublicKey: Task] = [:] private var groupQueries: [ConversationID: Task] = [:] + /// Keyed by ``LinkCard/resolutionKey``, a digest of the link, so the secret is held by the + /// query alone. + private var inviteQueries: [String: Task] = [:] private var userQueries: [LinkCard.User.Identity: Task, Never>] = [:] /// A message card's answer depends on the viewer's standing in the chat, which moves under it /// (a join turns Join into the message), so only the query in flight is held and a finished @@ -58,12 +63,14 @@ actor LinkCardResolver { cashLookup: @escaping @Sendable (String) async throws -> LinkCard.Cash.Resolved, mintLookup: @escaping @Sendable (PublicKey) async throws -> LinkCard.Token.Resolved, groupLookup: @escaping @Sendable (ConversationID) async throws -> GroupLinkFacts, + inviteLookup: @escaping @Sendable (InviteLinkSecret) async throws -> GroupLinkFacts = { _ in throw InviteLinkUnreadable() }, userLookup: @escaping @Sendable (LinkCard.User.Identity) async throws -> UserLinkFacts, messageLookup: @escaping @Sendable (LinkCard.Message) async throws -> LinkCard.Message.State = { _ in .unavailable } ) { self.cashLookup = cashLookup self.mintLookup = mintLookup self.groupLookup = groupLookup + self.inviteLookup = inviteLookup self.userLookup = userLookup self.messageLookup = messageLookup } @@ -91,6 +98,25 @@ actor LinkCardResolver { } } + /// The group a card names: by its id for a join link, through `GetInvite` for an invite link. + func group(_ card: LinkCard.Group) async -> GroupLinkFacts? { + if let chatID = card.chatID { return await group(chatID) } + guard let fragment = card.url.fragment(percentEncoded: true), + let secret = InviteLinkSecret(fragment: fragment) else { return nil } + let key = LinkCard.group(card).resolutionKey + if let query = inviteQueries[key] { return await query.value } + + let lookup = inviteLookup + let query = Task { + try? await lookup(secret) + } + inviteQueries[key] = query + + let facts = await query.value + if facts == nil, inviteQueries[key] == query { inviteQueries[key] = nil } + return facts + } + /// The group's public record, or nil if the lookup failed or the chat is gone. func group(_ chatID: ConversationID) async -> GroupLinkFacts? { if let query = groupQueries[chatID] { return await query.value } @@ -206,7 +232,13 @@ nonisolated extension LinkCard { switch self { case .cash(let cash): Self.cashKey(entropy: cash.entropy) case .token(let token): "token:\(token.mint.base58)" - case .group(let group): "group:\(group.chatID.description)" + case .group(let group): + if let chatID = group.chatID { + "group:\(chatID.description)" + } else { + // A digest, never the link: the fragment is the group's key. + "invite:\(SHA256.hash(data: Data(group.url.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined())" + } case .message(let message): "message:\(message.chatID.description):\(message.sequence)" case .user(let user): switch user.identity { @@ -247,6 +279,16 @@ nonisolated protocol GroupChatReading: Sendable { func getChat(owner: KeyPair, conversationID: ConversationID, viewMode: ConversationViewMode) async throws -> Conversation } +/// The one call an invite link's card needs: the group and the link's status, by its token. +nonisolated protocol GroupInviteReading: Sendable { + func getInvite(owner: KeyPair?, token: InviteToken) async throws -> (conversation: Conversation, status: Invite.Status) +} + +extension FlipClient: GroupInviteReading {} + +/// An invite card asked of a resolver built without an invite lookup. +nonisolated struct InviteLinkUnreadable: Error {} + extension FlipClient: GroupChatReading {} /// The one call a person card needs: a public profile, by whichever identity the link spells. @@ -346,6 +388,25 @@ extension LinkCardResolver { } } + /// The invite query: `GetInvite` by the link's token, which answers with the group's public + /// record and the link's status. The viewer is passed so a host's ban is reported. + static func inviteLookup( + invites: any GroupInviteReading, + mints: any MintMetadataReading, + viewer: KeyPair + ) -> @Sendable (InviteLinkSecret) async throws -> GroupLinkFacts { + { secret in + let (conversation, status) = try await invites.getInvite(owner: viewer, token: try secret.token()) + guard conversation.type == .group else { throw NotAGroup() } + + var mintName: String? + if let mint = headline(for: conversation.rules?.listener ?? [])?.mint, mint != .usdf { + mintName = try await mints.fetchMint(mint: mint).name + } + return GroupLinkFacts(conversation: conversation, headlineMintName: mintName, inviteStatus: status) + } + } + /// The profile query itself, and whose link it is. /// /// An unclaimed handle comes back as `Profile.empty`, whose `userID` is nil; that is not found, diff --git a/Flipcash/Core/Screens/Conversation/MessageLinkLookup.swift b/Flipcash/Core/Screens/Conversation/MessageLinkLookup.swift index c1b80f263..c3de23d64 100644 --- a/Flipcash/Core/Screens/Conversation/MessageLinkLookup.swift +++ b/Flipcash/Core/Screens/Conversation/MessageLinkLookup.swift @@ -256,10 +256,10 @@ extension LinkCardResolver { if let entropy = soleCashLink(in: text), let link = try? await cashLinks(entropy) { return .cash(.init(chatTitle: title, kind: kind(of: conversation), authorName: author, time: time(of: message), amount: link.amount, verb: .cashLink, claim: link.claim)) } - return wrapping(resolved(message, kind: kind(of: conversation), title: title, author: author, excerpt: text)) + return wrapping(resolved(message, kind: kind(of: conversation), title: title, author: author, excerpt: InviteLinkText.redacting(text))) case .media(let attachments, let caption): - let excerpt = caption ?? "" + let excerpt = InviteLinkText.redacting(caption ?? "") let photo = attachments.first.map { LinkCard.Message.Photo(blobID: message.redacted ? nil : $0.blobID, sealed: $0.sealed, blurHash: $0.blurhash) } diff --git a/Flipcash/Core/Screens/Main/Search/ChatSearch.swift b/Flipcash/Core/Screens/Main/Search/ChatSearch.swift index 9af630376..0b6d337e6 100644 --- a/Flipcash/Core/Screens/Main/Search/ChatSearch.swift +++ b/Flipcash/Core/Screens/Main/Search/ChatSearch.swift @@ -60,13 +60,14 @@ nonisolated struct ChatSearchMessageHit: Identifiable, Hashable { nonisolated enum ChatSearchMessages { /// The user-authored text a message can be found by: its body, a media caption, or a shared - /// profile's handle. Nil for content with no such text. + /// profile's handle. Nil for content with no such text. Invite links read as "Invite link", so + /// the group key in their fragment can't be searched for or shown in a hit. static func searchableText(of message: ConversationMessage) -> String? { switch message.content { case .text(let text): - text.isEmpty ? nil : text + text.isEmpty ? nil : InviteLinkText.redacting(text) case .media(_, let caption): - caption.flatMap { $0.isEmpty ? nil : $0 } + caption.flatMap { $0.isEmpty ? nil : InviteLinkText.redacting($0) } case .widget(.shareProfile(let widget)): widget.username.value case .widget(.unrecognized), .widget(.paymentRequest), .cash, .deleted, .encrypted, .system: diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index edba8d002..f53429e13 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -694,6 +694,7 @@ final class SessionContainer { /// Reads, makes and revokes a hosted private group's invite links, for ``GroupInviteLinks``. let inviteLinks: GroupInviteLinks.Service + let inviteLanding: GroupInviteLanding /// The group payment requests the signed-in user's private chats carry, refetched by the chat screen. let paymentRequests: PaymentRequests @@ -891,6 +892,7 @@ final class SessionContainer { cashLookup: LinkCardResolver.giftCardLookup(reader: client, viewer: owner, viewerAmount: viewerAmount), mintLookup: LinkCardResolver.mintLookup(reader: client), groupLookup: LinkCardResolver.groupLookup(chats: flipClient, mints: client, viewer: owner), + inviteLookup: LinkCardResolver.inviteLookup(invites: flipClient, mints: client, viewer: owner), userLookup: LinkCardResolver.userLookup(profiles: flipClient, viewer: owner, viewerID: session.userID), messageLookup: LinkCardResolver.messageLookup( chats: flipClient, @@ -946,6 +948,22 @@ final class SessionContainer { conversationController.groupRosters = groupRosters self.groupRosters = groupRosters + self.inviteLanding = GroupInviteLanding(service: .init( + owner: session.ownerKeyPair, + getInvite: { [flipClient, owner = session.ownerKeyPair] token in + try await flipClient.getInvite(owner: owner, token: token) + }, + getRoster: { [flipClient, owner = session.ownerKeyPair] conversationID, token in + try await flipClient.getRoster(owner: owner, conversationID: conversationID, inviteToken: token) + }, + join: { [conversationController] conversationID, invite in + try await conversationController.join(conversationID: conversationID, invite: invite) + }, + isMember: { [conversationController] conversationID in + conversationController.conversation(withID: conversationID).map { conversationController.isMember(of: $0) } ?? false + } + )) + let inviteSealer = SharedCoreInviteSealer() self.inviteLinks = GroupInviteLinks.Service( selfUserID: session.userID, diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 3d9b24dab..10aa99200 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -63,7 +63,7 @@ nonisolated struct ChatSpotlightItem { /// Mirrors the recipient picker's last-message subtitle. private static func preview(of message: ConversationMessage?, viewerAmount: (ExchangedFiat) -> FiatAmount) -> String? { switch message?.content { - case .text(let text): text + case .text(let text): InviteLinkText.redacting(text) case .cash(let amount): "Cash · \(viewerAmount(amount).formatted())" case .widget(.shareProfile): "Shared a profile" case .widget(.paymentRequest): "Request" diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift index 7ddc36651..3b04e77f3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift @@ -17,7 +17,7 @@ public enum ChatMediaStrings { /// A quote's snippet for a photo: its caption, or "Photo" when it has none. public static func quoteSnippet(caption: String?) -> String { - nonEmpty(caption) ?? "Photo" + nonEmpty(caption).map { InviteLinkText.redacting($0) } ?? "Photo" } /// A one-line preview of a photo: the camera glyph ahead of its caption, or ahead of "Photo". diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index 5897d576a..5fd22ff6d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -79,7 +79,7 @@ extension ChatItem { let content: ChatMessage.Content switch message.content { case .text(let text): - content = .text(text) + content = .text(InviteLinkText.redacting(text)) case .cash(let fiat): // The token name + coin icon come from `mintBranding`; an unresolved mint shows no // token label or icon rather than a misleading fallback. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift index 82d0ad2c3..cdc40fab7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift @@ -69,7 +69,7 @@ public struct ChatQuote: Hashable, Sendable, Codable { /// One line of preview text: newlines collapsed to spaces, truncated at ``snippetLimit``. public static func snippet(forText text: String) -> String { - let flattened = text + let flattened = InviteLinkText.redacting(text) .split(whereSeparator: \.isNewline) .joined(separator: " ") .trimmingCharacters(in: .whitespaces) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/InviteLinkText.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/InviteLinkText.swift new file mode 100644 index 000000000..eb3b99267 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/InviteLinkText.swift @@ -0,0 +1,65 @@ +// +// InviteLinkText.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Spots group invite links in message text, and keeps them out of the one-line previews built from it. +/// +/// An invite link's fragment is the group's key, so a preview — the chat list's last line, a reply's +/// quote, a notification, Spotlight, a search hit — shows "Invite link" in its place. The link is +/// recognised the way the in-chat card recognises it: a web link `LinkDetector` finds, unwrapped +/// from `jump.flipcash.com`, on a Flipcash host, at `/chat/invite`. Whether the secret parses does +/// not matter; a broken one is hidden all the same. +public enum InviteLinkText { + + /// What a preview shows in place of each invite link. + public static let placeholder = "Invite link" + + /// The hosts both apps claim. `Route.flipcashHosts` is this set, so a link is an invite link here + /// exactly when the card treats it as one. + public static let flipcashHosts: Set = [ + "app.flipcash.com", + "send.flipcash.com", + "flipcash.com", + "www.flipcash.com", + "jump.flipcash.com", + ] + + /// The real URL behind a `jump.flipcash.com` redirector, or nil if this is not one. + /// + /// A jump link carries its target percent-encoded in `#source=`. A jump pointing at another + /// jump is malformed and is not followed. Mirrors Android `DeepLink.unwrapJumpTarget`. + public static func unwrappingJump(_ url: URL) -> URL? { + let prefix = "https://jump.flipcash.com/#source=" + guard url.absoluteString.hasPrefix(prefix) else { return nil } + let encoded = String(url.absoluteString.dropFirst(prefix.count)) + guard let decoded = encoded.removingPercentEncoding, + let target = URL(string: decoded), + !decoded.hasPrefix(prefix) + else { return nil } + return target + } + + /// Whether `url` is a group invite link, with or without a usable secret. + public static func isInviteLink(_ url: URL) -> Bool { + let target = unwrappingJump(url) ?? url + guard let host = target.host()?.lowercased(), flipcashHosts.contains(host) else { return false } + let path = target.pathComponents.filter { $0 != "/" } + return path.count == 2 && path[0] == "chat" && path[1].lowercased() == "invite" + } + + /// `text` with every invite link replaced by ``placeholder``; everything else is left as it is. + public static func redacting(_ text: String, detector: LinkDetector = LinkDetector()) -> String { + let invites = detector.webLinks(in: text).filter { isInviteLink($0.url) } + guard !invites.isEmpty else { return text } + let result = NSMutableString(string: text) + for link in invites.reversed() { + result.replaceCharacters(in: link.range, with: placeholder) + } + return result as String + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift index 859734e6a..149045b62 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift @@ -201,20 +201,25 @@ public enum LinkCard: Hashable, Sendable, Codable { extension LinkCard { - /// A group chat's invite link, `app.flipcash.com/chat/{uuid}`. + /// A group chat's link: its join link, `app.flipcash.com/chat/{uuid}`, or a private group's + /// invite link, `app.flipcash.com/chat/invite#s=…`. public struct Group: Hashable, Sendable, Codable { public let url: URL - /// The chat id's bytes. Stored raw because `ConversationID` is not `Codable`. - public let chatIDData: Data + /// The chat id's bytes, or nil for an invite link, which names its group only through + /// `GetInvite`. Stored raw because `ConversationID` is not `Codable`. + public let chatIDData: Data? /// UTF-16 offsets into the message text — the same frame `DetectedLink` indexes in. public let location: Int public let length: Int public var range: NSRange { NSRange(location: location, length: length) } - /// The chat the link invites to. - public var chatID: ConversationID { ConversationID(data: chatIDData) } + /// The chat a join link names; nil for an invite link. + public var chatID: ConversationID? { chatIDData.map(ConversationID.init(data:)) } + + /// Whether this is an invite link, resolved by its secret rather than a chat id. + public var isInvite: Bool { chatIDData == nil } public init(url: URL, chatID: ConversationID, range: NSRange) { self.url = url @@ -223,6 +228,14 @@ extension LinkCard { self.length = range.length } + /// An invite link. Its secret stays in `url`'s fragment and is read where it is looked up. + public init(inviteURL url: URL, range: NSRange) { + self.url = url + self.chatIDData = nil + self.location = range.location + self.length = range.length + } + public enum State: Hashable, Sendable, Codable { /// No group to show: the lookup failed, or the chat is gone. Not remembered, so the /// next appearance asks again. @@ -603,7 +616,12 @@ nonisolated extension LinkCard { switch self { case .cash(let cash): .cash(Cash(url: cash.url, entropy: cash.entropy, range: range)) case .token(let token): .token(Token(url: token.url, mint: token.mint, range: range)) - case .group(let group): .group(Group(url: group.url, chatID: group.chatID, range: range)) + case .group(let group): + if let chatID = group.chatID { + .group(Group(url: group.url, chatID: chatID, range: range)) + } else { + .group(Group(inviteURL: group.url, range: range)) + } case .message(let message): .message(Message(url: message.url, chatID: message.chatID, messageID: message.messageID, range: range)) case .user(let user): .user(User(url: user.url, identity: user.identity, range: range)) case .web(let web): .web(Web(url: web.url, range: range, page: web.page)) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index d683799c2..de226ea51 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -99,7 +99,7 @@ public enum NotificationPayload { let preview: String switch message.content { case .text(let text): - preview = text + preview = InviteLinkText.redacting(text) case .media(_, let caption): preview = ChatMediaStrings.listPreview(caption: caption) case .cash, .deleted, .encrypted, .widget, .system: diff --git a/FlipcashCore/Tests/FlipcashCoreTests/InviteLinkTextTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/InviteLinkTextTests.swift new file mode 100644 index 000000000..b9c2e2df1 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/InviteLinkTextTests.swift @@ -0,0 +1,50 @@ +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("Invite links in preview text") +struct InviteLinkTextTests { + + private let secret = "kQ3vR8mZp2LxT7yW9bN4cD6fH1jK5sA0eG3iU8oQ2rY" + + @Test("A bare invite URL becomes \"Invite link\"") + func bareInviteURL() { + let text = "https://app.flipcash.com/chat/invite#s=\(secret)" + #expect(InviteLinkText.redacting(text) == "Invite link") + } + + @Test("The text around an invite URL stays") + func surroundingText() { + let text = "join us https://app.flipcash.com/chat/invite#s=\(secret) tonight" + let redacted = InviteLinkText.redacting(text) + #expect(redacted == "join us Invite link tonight") + #expect(!redacted.contains(secret)) + } + + @Test("An invite URL with a bad secret is hidden too, fragment and all") + func badSecret() { + let text = "here https://app.flipcash.com/chat/invite#s=not-a-secret" + let redacted = InviteLinkText.redacting(text) + #expect(redacted == "here Invite link") + #expect(!redacted.contains("not-a-secret")) + } + + @Test("A plain join link is left alone") + func plainJoinLink() { + let text = "come say hi https://app.flipcash.com/chat/2c5b8f0e-6b1a-4c1e-9d4b-3f0a7c2e8d11" + #expect(InviteLinkText.redacting(text) == text) + } + + @Test("A reply quote hides an invite link") + func previewSites() { + let text = "https://app.flipcash.com/chat/invite#s=\(secret)" + #expect(ChatQuote.snippet(forText: text) == "Invite link") + } + + @Test("A photo caption's invite link reads \"Invite link\" in a quote and a list preview") + func captionPreviews() { + let caption = "https://app.flipcash.com/chat/invite#s=\(secret)" + #expect(ChatMediaStrings.quoteSnippet(caption: caption) == "Invite link") + #expect(ChatMediaStrings.listPreview(caption: caption) == "📷 Invite link") + } +} diff --git a/FlipcashTests/Chat/ChatSearchTests.swift b/FlipcashTests/Chat/ChatSearchTests.swift index 55cd17429..64e200d57 100644 --- a/FlipcashTests/Chat/ChatSearchTests.swift +++ b/FlipcashTests/Chat/ChatSearchTests.swift @@ -62,6 +62,26 @@ struct ChatSearchTests { #expect(!ChatSearchQuery.matches(nil, "jeff")) } + // MARK: - Invite links + + private static let inviteSecret = "kQ3vR8mZp2LxT7yW9bN4cD6fH1jK5sA0eG3iU8oQ2rY" + + @Test("An invite link's secret can't be searched for, and a hit shows \"Invite link\"") + func inviteSecretNotSearchable() { + let messages = [text(1, "join us https://app.flipcash.com/chat/invite#s=\(Self.inviteSecret) tonight")] + #expect(ChatSearchMessages.hits(in: messages, query: "kQ3vR8").isEmpty) + #expect(ChatSearchMessages.hits(in: messages, query: "chat/invite").isEmpty) + #expect(ChatSearchMessages.hits(in: messages, query: "flipcash.com").isEmpty) + let hits = ChatSearchMessages.hits(in: messages, query: "tonight") + #expect(hits.map(\.text) == ["join us Invite link tonight"]) + } + + @Test("A photo caption's invite link is hidden from search too") + func inviteCaptionNotSearchable() { + let messages = [message(1, content: .media([], caption: "https://app.flipcash.com/chat/invite#s=\(Self.inviteSecret)"))] + #expect(ChatSearchMessages.hits(in: messages, query: "kQ3vR8").isEmpty) + } + // MARK: - Links @Test( diff --git a/FlipcashTests/Chat/LinkCardClassifierTests.swift b/FlipcashTests/Chat/LinkCardClassifierTests.swift index 3e29d9ca4..bb16a8cfd 100644 --- a/FlipcashTests/Chat/LinkCardClassifierTests.swift +++ b/FlipcashTests/Chat/LinkCardClassifierTests.swift @@ -80,6 +80,30 @@ import FlipcashCore #expect(LinkCardClassifier().cardOrWebLink(in: [link]) == nil) } + /// Spec surface 6: an invite link is a group card resolved by its secret, and never a web card. + @Test func anInviteLinkBecomesAnInviteGroupCard() throws { + let text = "https://app.flipcash.com/chat/invite#s=\(String(repeating: "A", count: 43))" + let url = try #require(URL(string: text)) + let link = DetectedLink(range: NSRange(location: 0, length: (text as NSString).length), url: url) + + guard case .group(let group)? = LinkCardClassifier().cardOrWebLink(in: [link]) else { + Issue.record("expected a group card") + return + } + #expect(group.isInvite) + #expect(group.chatID == nil) + // The resolver's key is a digest: the secret is not in it. + #expect(!LinkCard.group(group).resolutionKey.contains(String(repeating: "A", count: 43))) + } + + @Test func anInviteLinkWithoutASecretGetsNoCardOfAnyKind() throws { + let text = "https://app.flipcash.com/chat/invite" + let url = try #require(URL(string: text)) + let link = DetectedLink(range: NSRange(location: 0, length: (text as NSString).length), url: url) + + #expect(LinkCardClassifier().cardOrWebLink(in: [link]) == nil) + } + /// Not in the canonical fixture yet: Android refuses `/chat/` links until it ships the group /// card, and a shared vector would fail its copy until then. @Test func aGroupInviteLinkBecomesAGroupCard() throws { diff --git a/FlipcashTests/Chat/MessageLinkLookupTests.swift b/FlipcashTests/Chat/MessageLinkLookupTests.swift index 8ebb0a40c..fc56c2d75 100644 --- a/FlipcashTests/Chat/MessageLinkLookupTests.swift +++ b/FlipcashTests/Chat/MessageLinkLookupTests.swift @@ -188,6 +188,13 @@ import FlipcashCore #expect(value.excerpt == text) } + @Test func anInviteLinkInTheQuotedMessageReadsInviteLink() async throws { + let text = "come in https://app.flipcash.com/chat/invite#s=kQ3vR8mZp2LxT7yW9bN4cD6fH1jK5sA0eG3iU8oQ2rY" + let state = try await Self.state(held: Self.conversation(type: .group), member: true, message: .success(Self.message(.text(text)))) + guard case .resolved(let value) = state else { Issue.record("got \(state)"); return } + #expect(value.excerpt == "come in Invite link") + } + @Test func cashIsNeverRedactedForANonMember() async throws { let amount = ExchangedFiat( onChainAmount: TokenAmount(quarks: 15_000_000, mint: .usdf), From e74dd1a052418eed323cf2e9e489e1957662f608 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 14:41:50 -0400 Subject: [PATCH 3/4] fix(chat): name the inviter in an invite-link join notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The join notice parsed and stored invited_by but still worded an invite join as a plain "joined the group". It now reads "{name} joined with {inviter}'s invite link", "You joined with …" or "… with your invite link" (nodes 12846:374, 12846:427, 12846:480), as Android #1870 does. The inviter's name is bold and opens their profile. --- .../Conversation/ChatItem+Conversation.swift | 3 +- .../FlipcashCore/Models/Chat/ChatItem.swift | 6 +- .../Models/Chat/SystemNoticeStrings.swift | 44 ++++++++++++-- .../SystemNoticeStringsTests.swift | 26 ++++++++- .../Chat/ChatMessageMappingTests.swift | 4 +- FlipcashTests/Chat/UnreadDividerTests.swift | 2 +- .../Chat/ChatSystemMessageCell.swift | 58 +++++++++++++++++-- .../FlipcashUI/Chat/ChatViewController.swift | 11 ++-- 8 files changed, 131 insertions(+), 23 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 8c35a9a74..90e4d94d6 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -284,7 +284,8 @@ extension ChatItem { selfID: selfUserID, nameOf: nameOf ) - items.append(.systemMessage(id: "sys-\(message.stableID)", text: text, serverID: message.id)) + let userLink = SystemNoticeStrings.userLink(for: event, text: text, selfID: selfUserID, nameOf: nameOf) + items.append(.systemMessage(id: "sys-\(message.stableID)", text: text, serverID: message.id, userLink: userLink)) continue case .text(let text): content = .text(text) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index ed1101278..7ce344d0a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -27,8 +27,8 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { case encryptionMarker /// A server-authored notice drawn as a leading-aligned line, not a bubble. `text` is the server's /// fallback text, shown unmodified. `serverID` is the notice's message id, which the READ pointer - /// moves past like any inbound message's. - case systemMessage(id: String, text: String, serverID: MessageID?) + /// moves past like any inbound message's. `userLink` is the name in `text` that opens a profile. + case systemMessage(id: String, text: String, serverID: MessageID?, userLink: SystemNoticeUserLink? = nil) public var id: String { switch self { @@ -38,7 +38,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { // Independent of `typists`, so a typist joining or leaving updates the row in place. case .typingIndicator: "typing-indicator" case .encryptionMarker: "encryption-marker" - case .systemMessage(let id, _, _): id + case .systemMessage(let id, _, _, _): id } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift index dd82317c6..e82517edb 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift @@ -38,10 +38,46 @@ public enum SystemNoticeStrings { guard let admitterName = nameOf(admitter) else { return fallbackText } if isViewerJoiner { return "\(admitterName) added you" } return nameOf(joiner).map { "\(admitterName) added \($0)" } ?? fallbackText - case .userJoinedViaInvite(let joiner, _): - // Worded as a plain join until the invite notice copy is settled with Android. - if joiner == selfID { return "You joined the group" } - return nameOf(joiner).map { "\($0) joined the group" } ?? fallbackText + case .userJoinedViaInvite(let joiner, let inviter): + // Open 3 option B (nodes 12846:374, 12846:427, 12846:480). + if joiner == selfID { + return nameOf(inviter).map { "You joined with \($0)’s invite link" } ?? fallbackText + } + guard let joinerName = nameOf(joiner) else { return fallbackText } + if inviter == selfID { return "\(joinerName) joined with your invite link" } + return nameOf(inviter).map { "\(joinerName) joined with \($0)’s invite link" } ?? fallbackText } } + + /// The name in `text`, the result of ``text(for:fallbackText:selfID:nameOf:)`` for `event`, that + /// opens a profile: only the inviter of an invite join. "your" and "You" name the viewer, who + /// has no profile to open from a notice, so those notices have none. + public static func userLink( + for event: ConversationMessage.SystemEvent?, + text: String, + selfID: UserID?, + nameOf: (UserID) -> String? + ) -> SystemNoticeUserLink? { + guard case .userJoinedViaInvite(_, let inviter) = event, inviter != selfID, + let name = nameOf(inviter) else { return nil } + // The possessive anchors the match: the joiner's name may contain the inviter's. + guard let found = text.range(of: "\(name)’s", options: .backwards) else { return nil } + let nameEnd = text.index(found.lowerBound, offsetBy: name.count) + return SystemNoticeUserLink(range: NSRange(found.lowerBound.. SystemNoticeUserLink? { + let nameOf: (UUID) -> String? = { known[$0] } + let text = SystemNoticeStrings.text(for: event, fallbackText: "fallback", selfID: me, nameOf: nameOf) + return SystemNoticeStrings.userLink(for: event, text: text, selfID: me, nameOf: nameOf) + } + // The joiner's name contains the inviter's; the link is still the inviter's. + let other = link(.userJoinedViaInvite(userID: ada, invitedBy: bob), known: [ada: "Bob Jr", bob: "Bob"]) + #expect(other == SystemNoticeUserLink(range: NSRange(location: 19, length: 3), userID: bob)) + let mine = link(.userJoinedViaInvite(userID: me, invitedBy: bob), known: [bob: "Bob"]) + #expect(mine == SystemNoticeUserLink(range: NSRange(location: 16, length: 3), userID: bob)) + #expect(link(.userJoinedViaInvite(userID: ada, invitedBy: me), known: [ada: "Ada"]) == nil) + #expect(link(.userJoined(userID: ada, admittedBy: bob), known: [ada: "Ada", bob: "Bob"]) == nil) } @Test("A notice with no event shows its fallback text") diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index 62dac3f0a..9cab8d929 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -181,7 +181,7 @@ struct ChatMessageMappingTests { } private func systemTexts(_ items: [ChatItem]) -> [String] { - items.compactMap { if case .systemMessage(_, let text, _) = $0 { text } else { nil } } + items.compactMap { if case .systemMessage(_, let text, _, _) = $0 { text } else { nil } } } @Test("a join notice renders the names it is about") @@ -197,7 +197,7 @@ struct ChatMessageMappingTests { @Test("a system notice carries its message id for the READ pointer") func systemNoticeCarriesServerID() { let items = ChatItem.from([joinNotice(nil)], selfUserID: me, counterpartName: "Ada") - let ids = items.compactMap { if case .systemMessage(_, _, let serverID) = $0 { serverID } else { nil } } + let ids = items.compactMap { if case .systemMessage(_, _, let serverID, _) = $0 { serverID } else { nil } } #expect(ids == [MessageID(value: 1)]) } diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index 17a1394e7..e711c38f4 100644 --- a/FlipcashTests/Chat/UnreadDividerTests.swift +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -144,7 +144,7 @@ struct UnreadDividerTests { ) #expect(messageUnderDivider(items) == "3") #expect(dividerCount(items) == 1) - #expect(items.contains { if case .systemMessage(let id, _, _) = $0 { id == "sys-2" } else { false } }) + #expect(items.contains { if case .systemMessage(let id, _, _, _) = $0 { id == "sys-2" } else { false } }) } // MARK: - Resolution - diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift index cd4a35d44..192eb96c9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift @@ -8,10 +8,12 @@ #if canImport(UIKit) import SwiftUI import UIKit +import FlipcashCore /// A server-authored notice, centred in the transcript (node 11721:879, option D): an info glyph, /// then the text. No bubble, no fill, no sender, no gestures. The glyph is what tells it apart from -/// the date separator, which is also centred caption text. +/// the date separator, which is also centred caption text. An invite join's inviter is the one name +/// drawn bold, and tapping it opens that user's profile. public final class ChatSystemMessageCell: UICollectionViewCell { public static let reuseIdentifier = "ChatSystemMessageCell" @@ -22,8 +24,11 @@ public final class ChatSystemMessageCell: UICollectionViewCell { private static let textMaxWidth: CGFloat = 250 private static let textColor = UIColor(Color.textSecondary) private static let font = UIFont.appTextCaption + private static let linkFont = UIFont.default(size: 12, weight: .bold) private let label = UILabel() + private var userLink: SystemNoticeUserLink? + private var onUserTap: ((UserID) -> Void)? public override init(frame: CGRect) { super.init(frame: frame) @@ -51,6 +56,8 @@ public final class ChatSystemMessageCell: UICollectionViewCell { isAccessibilityElement = true accessibilityTraits = .staticText + label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(labelTapped(_:)))) + NSLayoutConstraint.activate([ group.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), group.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: ChatColumnCell.rowInset), @@ -73,20 +80,63 @@ public final class ChatSystemMessageCell: UICollectionViewCell { @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - /// Shows `text` unmodified, centred and wrapped at 250 points. - public func configure(text: String) { + /// Shows `text` unmodified, centred and wrapped at 250 points. `userLink`'s name is drawn bold, + /// and a tap on it calls `onUserTap`; a nil `onUserTap` leaves it inert. + public func configure(text: String, userLink: SystemNoticeUserLink? = nil, onUserTap: ((UserID) -> Void)? = nil) { let style = NSMutableParagraphStyle() style.minimumLineHeight = Self.lineHeight style.maximumLineHeight = Self.lineHeight style.alignment = .center - label.attributedText = NSAttributedString(string: text, attributes: [ + let attributed = NSMutableAttributedString(string: text, attributes: [ .font: Self.font, .foregroundColor: Self.textColor, .paragraphStyle: style, // Fixed line boxes sit the glyphs on the bottom; this centres them in the 18pt box. .baselineOffset: (Self.lineHeight - Self.font.lineHeight) / 2, ]) + let link = userLink.flatMap { NSMaxRange($0.range) <= attributed.length ? $0 : nil } + if let link { + attributed.addAttribute(.font, value: Self.linkFont, range: link.range) + } + label.attributedText = attributed + self.userLink = link + self.onUserTap = onUserTap + let tappable = link != nil && onUserTap != nil + label.isUserInteractionEnabled = tappable accessibilityLabel = text + accessibilityTraits = tappable ? [.staticText, .button] : .staticText + } + + // VoiceOver reads the notice as one element, so activating it stands in for the tap on the name. + public override func accessibilityActivate() -> Bool { + guard let userLink, let onUserTap else { return false } + onUserTap(userLink.userID) + return true + } + + @objc private func labelTapped(_ recognizer: UITapGestureRecognizer) { + guard let userLink, let onUserTap, let text = label.attributedText, + let index = characterIndex(at: recognizer.location(in: label), in: text), + NSLocationInRange(index, userLink.range) else { return } + onUserTap(userLink.userID) + } + + /// The character under `point` in the label, laid out the way the label lays out `text`. + private func characterIndex(at point: CGPoint, in text: NSAttributedString) -> Int? { + let storage = NSTextStorage(attributedString: text) + let layout = NSLayoutManager() + let container = NSTextContainer(size: label.bounds.size) + container.lineFragmentPadding = 0 + container.maximumNumberOfLines = label.numberOfLines + container.lineBreakMode = label.lineBreakMode + layout.addTextContainer(container) + storage.addLayoutManager(layout) + let glyph = layout.glyphIndex(for: point, in: container) + // A point beside the text maps to the nearest glyph; only one inside its box counts. + guard layout.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), in: container).contains(point) else { + return nil + } + return layout.characterIndexForGlyph(at: glyph) } } #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index e40201ed8..36e10cdbe 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -106,8 +106,8 @@ public final class ChatViewController: UICollectionViewController { /// Called when the "Encrypted" marker is tapped; nil leaves it inert. public var onEncryptionMarkerTap: (() -> 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. + /// Called when the user taps an author's face in the gutter, or the inviter's name in an invite + /// join notice; the argument is that user's id. Never fires in a DM, where no row draws one. public var onAuthorTap: ((UserID) -> Void)? /// Fired when a context-menu action other than Copy is chosen, with the row's id. Copy is handled @@ -697,8 +697,9 @@ public final class ChatViewController: UICollectionViewController { case .encryptionMarker: let tap: (() -> Void)? = onEncryptionMarkerTap == nil ? nil : { [weak self] in self?.onEncryptionMarkerTap?() } (cell as! ChatEncryptionMarkerCell).configure(onTap: tap) - case .systemMessage(_, let text, _): - (cell as! ChatSystemMessageCell).configure(text: text) + case .systemMessage(_, let text, _, let userLink): + let tap: ((UserID) -> Void)? = onAuthorTap == nil ? nil : { [weak self] in self?.onAuthorTap?($0) } + (cell as! ChatSystemMessageCell).configure(text: text, userLink: userLink, onUserTap: tap) case .message(let message): configure(cell, with: message) } @@ -1451,7 +1452,7 @@ public final class ChatViewController: UICollectionViewController { case .other: false } return ReadProgress.VisibleMessage(id: serverID, isFromSelf: isFromSelf) - case .systemMessage(_, _, let serverID): + case .systemMessage(_, _, let serverID, _): return serverID.map { ReadProgress.VisibleMessage(id: $0, isFromSelf: false) } case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker: return nil From 314601709cb7ee2ac017c3e9efee64ade6d40d07 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 14:59:10 -0400 Subject: [PATCH 4/4] feat(chat): give the invite preview the profile's overflow menu A non-member sees Encryption and Report, as on Android's invite landing; Mute needs viewer state the preview doesn't have. --- .../Invite Links/GroupInviteScreen.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift b/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift index 8878ba16c..ae172f108 100644 --- a/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift @@ -31,6 +31,8 @@ struct GroupInviteScreen: View { @State private var isJoining = false @State private var isRequesting = false @State private var dialogItem: DialogItem? + @State private var isShowingE2ee = false + @State private var isReporting = false enum Phase { case loading @@ -144,11 +146,48 @@ struct GroupInviteScreen: View { .padding(.top, 12) .padding(.bottom, 8) } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + overflowMenu + } + } + .sheet(isPresented: $isShowingE2ee) { + E2eeLearnMoreSheet(kind: .privateGroup, isPresented: $isShowingE2ee) + } + .fullScreenCover(isPresented: $isReporting) { + NavigationStack { + ReportFlowScreen(target: .chat(id)) + } + } .task(id: id) { await sessionContainer.profileAvatars.load(.chat(id), picture: conversation.picture) } } + /// The profile's menu as a non-member sees it: no Mute, since there's no viewer state to mute. + private var overflowMenu: some View { + Menu { + Button { + isShowingE2ee = true + } label: { + Label("Encryption", systemImage: "lock.fill") + } + Divider() + Button(role: .destructive) { + isReporting = true + } label: { + Label("Report", systemImage: "exclamationmark.bubble") + } + // Menu icons follow the app's white tint while the title follows the role. + .tint(.red) + } label: { + Image.system(.ellipsis) + .renderingMode(.template) + } + .accessibilityLabel("More") + .accessibilityIdentifier("group-invite-overflow") + } + /// A dead link can still lead in: the host approves a request, as on the group's profile. @ViewBuilder private func requestActions(_ id: ConversationID) -> some View {