Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)"
Expand Down
19 changes: 19 additions & 0 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<userId>` link.
case tip(UserID)
/// The profile a `flipcash.com/<userId>` or `flipcash.com/<handle>` link names.
Expand All @@ -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"
Expand Down
42 changes: 26 additions & 16 deletions Flipcash/Core/Controllers/Deep Links/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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<String> = [
"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<String> = InviteLinkText.flipcashHosts

/// Whether `url` is one of ours: the custom scheme, or an exact match on ``flipcashHosts``.
///
Expand Down Expand Up @@ -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=<secret>`. 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/<userId>`, which stays parseable for
/// links already shared and still opens the tipcard.
case tip(UserID)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
8 changes: 6 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down Expand Up @@ -254,6 +257,7 @@ extension AppRouter {
case .joinRequests: "joinRequests"
case .inviteLinks: "inviteLinks"
case .groupMembers: "groupMembers"
case .groupInvite: "groupInvite"
}
}

Expand Down Expand Up @@ -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
}
}
Expand Down
3 changes: 3 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion Flipcash/Core/Screens/Conversation/GroupLinkCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Conversation/LinkCardFeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading