diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index d50c772ba..6594a6a5e 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) @@ -1671,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/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..61e47dc3d 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 @@ -85,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) } } @@ -104,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``. /// @@ -148,6 +148,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 +209,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/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/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/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..ae172f108 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteScreen.swift @@ -0,0 +1,334 @@ +// +// 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? + @State private var isShowingE2ee = false + @State private var isReporting = false + + 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) + } + .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 { + 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/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/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/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/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.. 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/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/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift index 471e57d0e..913462576 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift @@ -65,10 +65,30 @@ struct SystemNoticeStringsTests { #expect(text(.userJoined(userID: bob, admittedBy: me)) == "fallback") } - @Test("An invite join reads as a plain join") + @Test("An invite join names whose invite link it was") func invitedJoin() { - #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: bob), known: [ada: "Ada", bob: "Bob"]) == "Ada joined the group") - #expect(text(.userJoinedViaInvite(userID: me, invitedBy: bob)) == "You joined the group") + let known = [ada: "Ada", bob: "Bob"] + #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: bob), known: known) == "Ada joined with Bob’s invite link") + #expect(text(.userJoinedViaInvite(userID: me, invitedBy: bob), known: known) == "You joined with Bob’s invite link") + #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: me), known: known) == "Ada joined with your invite link") + #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: bob), known: [ada: "Ada"]) == "fallback") + #expect(text(.userJoinedViaInvite(userID: ada, invitedBy: me)) == "fallback") + } + + @Test("Only the inviter's name in an invite join opens a profile") + func inviterLink() { + func link(_ event: ConversationMessage.SystemEvent, known: [UUID: String]) -> 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/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/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/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), 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/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 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