diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 6ffd4700b..77d8f16b3 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -45,8 +45,9 @@ final class DeepLinkController { /// The canonical deep-link entry: dedups opens of the same URL within a short window, records /// analytics, and executes the parsed action. Returns false when the URL parses to no action. + /// `sourceChat` is the chat the link was tapped in, which a message link offers a way back to. @discardableResult - func open(_ url: URL, origin: DeepLinkOrigin = .link) -> Bool { + func open(_ url: URL, origin: DeepLinkOrigin = .link, sourceChat: ConversationID? = nil) -> Bool { // Drop duplicate deliveries: a second claim is rejected server-side as // stale state and surfaces as a false error after the first succeeded. // A scene link can arrive twice — once from `SceneDelegate`, and @@ -61,6 +62,7 @@ final class DeepLinkController { Analytics.deeplinkOpened(url: url) var action = handle(open: url) action?.origin = origin + action?.sourceChat = sourceChat // Only record a parse result for URLs that resolve to an action. Chat links now route through // here too, and most are ordinary web URLs — logging every non-match as a "failed to parse" // error would bury genuine deep-link parse failures in expected noise. @@ -166,6 +168,9 @@ final class DeepLinkController { case .chatSendCash(let conversationID): return action(.chatSendCash(conversationID)) + case .chatMessage(let conversationID, let messageID): + return action(.chatMessage(conversationID, messageID)) + case .tip(let userID): return action(.tip(userID)) @@ -202,6 +207,9 @@ struct DeepLinkAction { /// Where the URL that produced this action entered the app. var origin: DeepLinkOrigin = .link + + /// The chat the link was tapped in, or nil when it came from outside a chat. + var sourceChat: ConversationID? private let sessionAuthenticator: SessionAuthenticator @@ -221,7 +229,14 @@ struct DeepLinkAction { /// /// `GetGroupChatFeed` carries only the groups the user has joined, so for a group they have not /// joined this link is the only way in — and the screen it lands on is the one that offers the join. - private static func routeChat(_ conversationID: ConversationID, origin: DeepLinkOrigin, in container: SessionContainer) async { + /// + /// `landing` names the message a message link points at; the transcript opens on it. + private static func routeChat( + _ conversationID: ConversationID, + landing: MessageLanding? = nil, + origin: DeepLinkOrigin, + in container: SessionContainer + ) async { let conversation = await container.conversationController.hydratedConversation(withID: conversationID) let isPrivateOutsider = conversation.map { @@ -230,7 +245,8 @@ struct DeepLinkAction { guard let destination = chatDestination( for: conversation?.type, isPrivateOutsider: isPrivateOutsider, - conversationID: conversationID + conversationID: conversationID, + landing: landing ) else { logger.info("Ignoring non-tip chat deeplink", metadata: [ "conversationID": "\(conversationID)", @@ -244,7 +260,15 @@ struct DeepLinkAction { Analytics.groupInviteFollowed(source: source) } - container.appRouter.navigate(to: destination) + // A message link tapped in another chat goes on top of it, so the back chip and the back + // chevron return to that chat where the reader left it. + let router = container.appRouter + if case .chatMessage(let id, var landing) = destination, landing.returnTo != nil, router.canNavigateOver(destination) { + landing.isOverReturnChat = true + router.push(.chatMessage(id, landing)) + } else { + router.navigate(to: destination) + } } /// The screen a chat id opens, or nil when it isn't one the app surfaces. @@ -252,16 +276,20 @@ struct DeepLinkAction { /// A group routes to the same transcript a tip DM does whether or not the viewer is a member: /// the screen gates itself, so an invite link and a push tap land on one destination. The /// exception is a private group the viewer isn't in, which has no chat they can read; it opens - /// the profile, where they ask to join. + /// the profile, where they ask to join, and a message link into it lands there too. static func chatDestination( for type: ConversationType?, isPrivateOutsider: Bool = false, - conversationID: ConversationID + conversationID: ConversationID, + landing: MessageLanding? = nil ) -> AppRouter.Destination? { switch type { case .group where isPrivateOutsider: return .chatProfile(conversationID, origin: .featuredGroup) case .tipDm, .group: + if let landing { + return .chatMessage(conversationID, landing) + } return .tipConversation(conversationID) case .contactDm, nil: return nil @@ -419,6 +447,19 @@ struct DeepLinkAction { await Self.routeChat(conversationID, origin: origin, in: container) } + case .chatMessage(let conversationID, let messageID): + if let container = sessionAuthenticator.loggedInContainer { + Analytics.deeplinkRouted(kind: kind) + // A link back into the chat it was tapped in has nothing to return to. + let returnTo = sourceChat == conversationID ? nil : sourceChat + await Self.routeChat( + conversationID, + landing: MessageLanding(messageID: messageID, returnTo: returnTo), + origin: origin, + in: container + ) + } + case .chatSendCash(let conversationID): if let container = sessionAuthenticator.loggedInContainer { // Only tip DMs take the Send Cash action; contact/phone sends are no longer @@ -495,6 +536,8 @@ extension DeepLinkAction { case currencyInfo(PublicKey) case chat(ConversationID) case chatSendCash(ConversationID) + /// One message in a chat: the chat, opened on that message. + case chatMessage(ConversationID, MessageID) /// The tipcard for the legacy `/tip/` link. case tip(UserID) /// The profile a `flipcash.com/` or `flipcash.com/` link names. @@ -516,6 +559,7 @@ extension DeepLinkAction.Kind { case .currencyInfo: "TokenInfo" case .chat: "Chat" case .chatSendCash: "ChatSendCash" + case .chatMessage: "ChatMessage" 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 b719bd682..a4388ddd0 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -145,6 +145,9 @@ nonisolated extension Route { case token(PublicKey) case chat(ConversationID) case chatSendCash(ConversationID) + /// 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) /// The older tipcard link, `/tip/`, which stays parseable for /// links already shared and still opens the tipcard. case tip(UserID) @@ -210,6 +213,11 @@ nonisolated extension Route { else { return nil } + // `/chat/{id}/m/{seq}` names one message. A tail that isn't a sequence number + // opens the chat, the same as a build that predates message links. + if components.count > 3, components[2] == "m", let seq = UInt64(components[3]) { + return .chatMessage(id, MessageID(value: seq)) + } // `/chat/{id}/send` opens the Send Cash sheet over the chat. if components.count > 2, components[2] == "send" { return .chatSendCash(id) diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 06661d3a6..840ef6d35 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -116,6 +116,9 @@ extension AppRouter { /// trace shows "post-tip, keyboard up" distinctly, and so the ordinary /// tip-list / push-notification opens stay keyboard-closed untouched. case tipConversationWithKeyboard(ConversationID) + /// Same screen as `tipConversation`, opened on one message from a message link. A sibling + /// case for the same reason as `tipConversationWithKeyboard`: the ordinary opens stay untouched. + case chatMessage(ConversationID, MessageLanding) /// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face; /// hosts the Block action. `origin` decides whether it offers a way into the DM. case userProfile(UserID, origin: UserProfileOrigin) @@ -165,7 +168,7 @@ extension AppRouter { .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw: return .you case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, - .tipConversation, .tipConversationWithKeyboard, .userProfile, .chatProfile, + .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .editGroupCover, .editGroupDescription, .archivedChats, .requests, .joinRequests, .groupMembers: return .tips @@ -221,6 +224,7 @@ extension AppRouter { case .newPrivateGroup: "newPrivateGroup" case .tipConversation: "tipConversation" case .tipConversationWithKeyboard: "tipConversationWithKeyboard" + case .chatMessage: "chatMessage" case .userProfile: "userProfile" case .chatProfile: "chatProfile" case .editGroup: "editGroup" @@ -254,6 +258,7 @@ extension AppRouter { return activity.id.base58 case .tipConversation(let conversationID), .tipConversationWithKeyboard(let conversationID), + .chatMessage(let conversationID, _), .chatProfile(let conversationID, _), .editGroup(let conversationID), .editGroupName(let conversationID), diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 70cf1c115..ba7967531 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -244,6 +244,11 @@ struct DestinationView: View { ConversationScreen(context: .existing(conversationID), openKeyboard: true) .id(conversationID) + case .chatMessage(let conversationID, let landing): + // A message link: same screen, opened on the linked message. + ConversationScreen(context: .existing(conversationID), landing: landing) + .id(conversationID) + case .userProfile(let userID, let origin): UserProfileScreen(userID: userID, origin: origin) .id(userID) diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 2d7086182..eaabf96fa 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -576,13 +576,19 @@ final class AppRouter { /// destination is pushed onto it rather than replacing its path, so backing out returns to the /// screen the user was on when the link was followed. func navigateOver(_ destination: Destination) { - guard presentedSheets.isEmpty, activeTabStack == destination.owningStack else { + guard canNavigateOver(destination) else { navigate(to: destination) return } push(destination) } + /// Whether `navigateOver(_:)` would push `destination` onto the visible surface rather than + /// replace its stack's path. + func canNavigateOver(_ destination: Destination) -> Bool { + presentedSheets.isEmpty && activeTabStack == destination.owningStack + } + /// Surfaces the wallet at its root — where a grabbed deposit lands after /// "Put in Wallet". /// diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 353251403..b3bfcc737 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -138,6 +138,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var onMediaTap: (ChatMediaViewerRequest) -> Void = { _ in } /// Room the transcript reserves under the navigation bar for the join-requests anchor. var transcriptTopInset: CGFloat = 0 + /// The latest request to land on a message, from a message link; each new token scrolls once. + var messageJump: MessageJump? = nil func makeUIViewController(context: Context) -> ChatScreenViewController { let jump = JumpToBottomModel() @@ -206,6 +208,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.unreadBelowCount = unreadBelowCount screen.onUserScrollBegan = onUserScrollBegan screen.reportsReads = reportsReads + perform(messageJump, on: screen, coordinator: context.coordinator) screen.update(items: items) context.coordinator.barHost = barHost context.coordinator.screen = screen @@ -275,6 +278,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let lastIsOwnMessage = if case .message(let message) = newLastMessage { message.sender == .me } else { false } let appendedOwn = newLastMessageID != context.coordinator.lastMessageID && lastIsOwnMessage let wasAtBottom = screen.isTranscriptAtBottom + // Before the items land, so a row still outside the window is recorded as the pending + // target and the update that brings it in positions on it instead of at the bottom. + perform(messageJump, on: screen, coordinator: context.coordinator) screen.update(items: items) if appendedOwn, !wasAtBottom { screen.scrollToBottom(animated: true) @@ -282,6 +288,13 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { context.coordinator.lastMessageID = newLastMessageID } + /// Scrolls to `jump`'s message once per token. + private func perform(_ jump: MessageJump?, on screen: ChatScreenViewController, coordinator: Coordinator) { + guard let jump, jump.token != coordinator.lastJumpToken else { return } + coordinator.lastJumpToken = jump.token + screen.scrollToMessage(id: jump.stableID) + } + /// Wraps the action handler so the screen follows the action. The menu dismissed the keyboard /// to present itself and the composer can't drive it back on its own — a hosted SwiftUI /// `@FocusState` doesn't make the field first responder — so only the screen can raise it for an @@ -389,6 +402,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let overlay: AttachKeyboardOverlay /// What the attach menu and its cards hand back, as of the latest render. var attachActions = AttachActions() + /// The last message jump performed, so a re-render doesn't scroll again. + var lastJumpToken: Int? init(model: ConversationBarModel, composer: ComposerModel) { self.model = model diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 1f6e31db0..5e70c11a7 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -42,6 +42,9 @@ struct ConversationScreen: View { /// post-tip navigation; every other entry point opens keyboard-closed. var openKeyboard: Bool = false + /// The message a message link opened this chat on, or nil for every other entry point. + var landing: MessageLanding? = nil + @Environment(ConversationController.self) private var conversationController @Environment(ContactSyncController.self) private var contactSyncController @Environment(AppRouter.self) private var router @@ -93,6 +96,15 @@ struct ConversationScreen: View { /// The catalog's first category, in catalog order, that fills the strip after the recents. @State private var stripCatalog: [String] = [] + /// The latest request to scroll to a linked message; each new one scrolls once. + @State private var messageJump: MessageJump? + /// The landing last acted on, so a reload doesn't jump again while a new link into this + /// already-open chat (same screen identity, new `landing`) still does. + @State private var landedOn: MessageLanding? + /// The chat the back chip returns to, or nil while it is hidden. Set by a landing from another + /// chat; the chip stays until it is tapped or the reader leaves this chat. + @State private var backChipTarget: ConversationID? + /// The join-requests anchor's measured height, reserved at the transcript's top; 0 while hidden. @State private var anchorHeight: CGFloat = 0 /// Whether the keyboard is up, which holds the update toast back. @@ -447,7 +459,8 @@ struct ConversationScreen: View { mintMediaURL: mintMediaURL, mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer, - transcriptTopInset: anchorHeight > 0 ? anchorHeight + 8 : 0 + transcriptTopInset: anchorHeight > 0 ? anchorHeight + 8 : 0, + messageJump: messageJump ) } @@ -558,6 +571,7 @@ struct ConversationScreen: View { private func chrome(_ content: some View) -> some View { content .animation(.default, value: reactionToastText) + .animation(.default, value: backChipTarget) .animation(.default, value: hostRequests.map(\.userID)) .task(id: hostedConversationID) { guard let hostedConversationID else { return } @@ -618,6 +632,18 @@ struct ConversationScreen: View { // display, under the status bar, rather than below the navigation bar. .overlay(alignment: .top) { VStack(spacing: 8) { + if let backChipTarget { + MessageLinkBackChip(title: conversationController.displayName(forConversationID: backChipTarget)) { + if landing?.isOverReturnChat == true { + router.popTopmost() + } else { + router.navigate(to: .tipConversation(backChipTarget)) + } + } + // 10pt under the bar, with the stack's own 8. + .padding(.top, 2) + .transition(.opacity.combined(with: .move(edge: .top))) + } if !hostRequests.isEmpty, let conversationID { JoinRequestsAnchor( requests: hostRequests, @@ -832,6 +858,14 @@ struct ConversationScreen: View { .onChange(of: gate, initial: true) { _, gate in reportGateShown(gate) } + // A message link lands once the opening read has stored the newest page, so paging back + // to the message starts from history the database holds. + .onChange(of: didInitialRead, initial: true) { _, _ in + landIfNeeded() + } + .onChange(of: landing) { _, _ in + landIfNeeded() + } .onChange(of: gate.obscuresTranscript) { _, obscures in // The opening task read the gate it started with, which for a chat reached by link or // push is `.undetermined`: a non-member the rules admit, or a balance that crosses the @@ -1148,6 +1182,26 @@ struct ConversationScreen: View { _ = coordinator?.loader.reveal(MessageID(value: value)) } + /// Brings `messageID` into the window, paging older history when it isn't stored, then scrolls + /// to it and flashes it. A message the server no longer has leaves the transcript where it is. + private func land(on messageID: MessageID) { + guard let loader = coordinator?.loader else { return } + Task { + guard await loader.revealPaging(messageID) else { + session.dialogItem = .info(title: "Message Unavailable", subtitle: "This message may have been deleted.") + return + } + messageJump = MessageJump(stableID: String(messageID.value), token: (messageJump?.token ?? 0) + 1) + } + } + + private func landIfNeeded() { + guard didInitialRead, let landing, landing != landedOn else { return } + landedOn = landing + backChipTarget = landing.returnTo + land(on: landing.messageID) + } + private func confirmDelete(_ messageID: MessageID) { guard let conversationID else { return } @@ -1321,8 +1375,15 @@ struct ConversationScreen: View { } private func openLink(_ url: URL) { + // A link to a message in this chat jumps in place; routing it would remount the chat. + if Route.isFlipcashLink(url), + case .chatMessage(let chatID, let messageID)? = Route(url: url)?.path, + chatID == conversationID { + land(on: messageID) + return + } ChatLinkOpener( - openDeepLink: { container.deepLinkController.open($0) }, + openDeepLink: { container.deepLinkController.open($0, sourceChat: conversationID) }, openExternally: { ExternalLinkOpener(session: session, trustedWebsites: container.trustedWebsites).open($0) } diff --git a/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift b/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift index bb4b54220..b501f1132 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift @@ -108,7 +108,7 @@ nonisolated struct LinkCardClassifier { // anything is a question only the lookup can answer. return .token(LinkCard.Token(url: target, mint: mint, range: link.range)) - case .chat(let chatID): + case .chat(let chatID), .chatMessage(let chatID, _): // A group invite. Whether the id names a group this viewer can see is, as with a mint, // a question for the lookup; a chat that turns out not to exist renders unavailable. return .group(LinkCard.Group(url: target, chatID: chatID, range: link.range)) diff --git a/Flipcash/Core/Screens/Conversation/MessageLanding.swift b/Flipcash/Core/Screens/Conversation/MessageLanding.swift new file mode 100644 index 000000000..46e276693 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/MessageLanding.swift @@ -0,0 +1,26 @@ +// +// MessageLanding.swift +// Flipcash +// + +import FlipcashCore + +/// Where a message link opens a chat: the message to land on, and the chat the link was tapped in, +/// which the transcript offers a way back to. +nonisolated struct MessageLanding: Hashable, Sendable { + /// The linked message, by its per-chat id. + let messageID: MessageID + /// The chat the link was tapped in, or nil when it came from outside a chat. + let returnTo: ConversationID? + /// Whether the landed chat was pushed over `returnTo`'s screen, so going back pops to that + /// screen as the reader left it rather than reopening it at the bottom. + var isOverReturnChat = false +} + +/// One request to scroll the transcript to a message and flash it. `token` makes a repeat jump to +/// the same message a new request. +struct MessageJump: Equatable { + /// The row's stable id: the message id's value as a string. + let stableID: String + let token: Int +} diff --git a/Flipcash/Core/Screens/Conversation/MessageLoader.swift b/Flipcash/Core/Screens/Conversation/MessageLoader.swift index 486ef9814..7baa36ca7 100644 --- a/Flipcash/Core/Screens/Conversation/MessageLoader.swift +++ b/Flipcash/Core/Screens/Conversation/MessageLoader.swift @@ -47,16 +47,49 @@ final class MessageLoader { /// Brings `messageID` into the rendered window, returning whether it is now there. /// /// One anchor move, not a paging loop: an anchored window reads every message from `startID` - /// forward, so pointing the anchor at the target is enough. Nothing is fetched — a message the + /// forward, so anchoring at or just before the target is enough. The anchor sits `leadIn` + /// messages early where history allows, so the transcript can center the target instead of + /// pinning it as the cut-off top row. Nothing is fetched — a message the /// local database has never seen returns `false` and the caller leaves the transcript alone. @discardableResult func reveal(_ messageID: MessageID) -> Bool { if messages.contains(where: { $0.id == messageID }) { return true } guard controller.persistedMessage(messageID, in: conversationID) != nil else { return false } - startID = messageID.value + startID = controller.olderAnchor(for: conversationID, before: messageID.value, step: Self.leadIn) ?? messageID.value return true } + /// Brings `messageID` into the rendered window, paging older history from the server until the + /// local database holds it. Returns whether it is now there. + /// + /// Pages contiguously back from the oldest stored message rather than fetching around the target, + /// because the anchored window reads every message from `startID` forward and a detached page + /// would leave a hole. Gives up after `pageLimit` pages, or once the server has no older history. + func revealPaging(_ messageID: MessageID, pageLimit: Int = MessageLoader.revealPageLimit) async -> Bool { + var pages = 0 + while !reveal(messageID) { + guard pages < pageLimit, controller.hasMoreOlderMessages(for: conversationID) else { return false } + if controller.isLoadingOlderMessages(for: conversationID) { + // The transcript's own top-of-scroll paging is mid-page; wait for it rather than skip it. + try? await Task.sleep(for: .milliseconds(100)) + if Task.isCancelled { return false } + continue + } + await controller.loadOlderMessages(for: conversationID) + pages += 1 + } + // A page that ends on the target leaves nothing above it to center against; one more page fixes that. + if pages > 0, pages < pageLimit, startID == messageID.value, controller.hasMoreOlderMessages(for: conversationID), + !controller.isLoadingOlderMessages(for: conversationID) { + await controller.loadOlderMessages(for: conversationID) + startID = controller.olderAnchor(for: conversationID, before: messageID.value, step: Self.leadIn) ?? messageID.value + } + return true + } + + /// The most server pages `revealPaging` fetches on the way to a linked message. + static let revealPageLimit = 20 + /// Reveals an older step: moves the anchor back over already-persisted history, or pages the next /// older batch from the server (which persists it) once the local history is exhausted. func loadOlder() { @@ -90,4 +123,6 @@ final class MessageLoader { /// How many of the newest messages a fresh open renders. static let initialWindow = 60 private static let step = 40 + /// How many messages a revealed target keeps above it. + private static let leadIn = 12 } diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index 7d31f4abb..60863db9e 100644 --- a/Flipcash/Core/Screens/Main/ScanViewModel.swift +++ b/Flipcash/Core/Screens/Main/ScanViewModel.swift @@ -137,7 +137,7 @@ class ScanViewModel { /// so nothing vouched for its host, and `Route` matches on path alone — ungated, a Discord /// invite scans as a handle and a stranger's `/c/#/e=…` scans as a cash link. /// - /// Then the route: an allowlist of `.cash`, `.token`, `.tip`, `.profile`, `.username`, and `.chat`, with + /// Then the route: an allowlist of `.cash`, `.token`, `.tip`, `.profile`, `.username`, `.chat`, and `.chatMessage`, with /// every other route refused by name — including the security-sensitive `.login` and /// `.verifyEmail`. A new `Route.Path` case is refused until someone adds it here. /// @@ -154,8 +154,8 @@ class ScanViewModel { case .cash, .token, .tip, .profile, .username: return true // A group's invite link is printed as a QR; opening it lands on the - // transcript, which gates the join itself. - case .chat: + // transcript, which gates the join itself. A message link lands on the same transcript. + case .chat, .chatMessage: return true case .login, .verifyEmail, .chatSendCash, .give, .balance, .discover, .unknown: return false diff --git a/FlipcashTests/Chat/MessageLoaderRevealTests.swift b/FlipcashTests/Chat/MessageLoaderRevealTests.swift index c99e45f81..1167c9010 100644 --- a/FlipcashTests/Chat/MessageLoaderRevealTests.swift +++ b/FlipcashTests/Chat/MessageLoaderRevealTests.swift @@ -62,6 +62,15 @@ struct MessageLoaderRevealTests { #expect(loader.messages.contains { $0.id == target }) } + @Test("A revealed message keeps older messages above it, so it isn't the cut-off top row") + func revealOlderMessage_leavesLeadIn() throws { + let (loader, url) = try makeLoader(messageCount: 200) + defer { Database.removeTemp(at: url) } + + #expect(loader.reveal(MessageID(value: 100))) + #expect(loader.messages.first?.id == MessageID(value: 88)) + } + @Test("A message the database has never seen cannot be revealed") func revealUnknownMessage_fails() throws { let (loader, url) = try makeLoader(messageCount: 10) diff --git a/FlipcashTests/DeepLinkControllerTests.swift b/FlipcashTests/DeepLinkControllerTests.swift index def317555..6ad2807da 100644 --- a/FlipcashTests/DeepLinkControllerTests.swift +++ b/FlipcashTests/DeepLinkControllerTests.swift @@ -160,4 +160,71 @@ struct DeepLinkControllerTests { _ = controller.open(url) try await waitUntil { controller.open(url) == false } } + + // MARK: - Chat links - + + private static let chatID = ConversationID(data: Data((0..<32).map { UInt8($0) })) + + private static func chatLink(_ tail: String = "") -> URL { + URL(string: "https://app.flipcash.com/chat/\(chatID.linkPathComponent)\(tail)")! + } + + @Test("A message link opens the chat on that message") + func messageLink_opensChatMessage() throws { + let kind = try #require(makeController().handle(open: Self.chatLink("/m/42"))?.kind) + guard case .chatMessage(let chatID, let messageID) = kind else { + Issue.record("/m/{seq} should dispatch .chatMessage, got \(kind.analyticsName)") + return + } + #expect(chatID == Self.chatID) + #expect(messageID == MessageID(value: 42)) + } + + @Test("A bare chat link still opens the chat") + func chatLink_opensChat() throws { + let kind = try #require(makeController().handle(open: Self.chatLink())?.kind) + guard case .chat(let chatID) = kind else { + Issue.record("/chat/{id} should dispatch .chat, got \(kind.analyticsName)") + return + } + #expect(chatID == Self.chatID) + } + + @Test("The Send Cash chat link still opens Send Cash") + func sendCashLink_opensSendCash() throws { + let kind = try #require(makeController().handle(open: Self.chatLink("/send"))?.kind) + guard case .chatSendCash(let chatID) = kind else { + Issue.record("/chat/{id}/send should dispatch .chatSendCash, got \(kind.analyticsName)") + return + } + #expect(chatID == Self.chatID) + } + + @Test("A message link whose sequence isn't a number opens the chat", + arguments: ["/m/abc", "/m/", "/m/-1", "/m"]) + func malformedMessageLink_opensChat(tail: String) throws { + let kind = try #require(makeController().handle(open: Self.chatLink(tail))?.kind) + guard case .chat(let chatID) = kind else { + Issue.record("\(tail) should fall back to .chat, got \(kind.analyticsName)") + return + } + #expect(chatID == Self.chatID) + } + + @Test("A message link lands in a readable chat and drops the message for a private outsider") + func chatDestination_withLanding() { + let landing = MessageLanding(messageID: MessageID(value: 42), returnTo: nil) + #expect( + DeepLinkAction.chatDestination(for: .group, conversationID: Self.chatID, landing: landing) + == .chatMessage(Self.chatID, landing) + ) + #expect( + DeepLinkAction.chatDestination(for: .tipDm, conversationID: Self.chatID, landing: landing) + == .chatMessage(Self.chatID, landing) + ) + #expect( + DeepLinkAction.chatDestination(for: .group, isPrivateOutsider: true, conversationID: Self.chatID, landing: landing) + == .chatProfile(Self.chatID, origin: .featuredGroup) + ) + } } diff --git a/FlipcashTests/MessageLoaderTests.swift b/FlipcashTests/MessageLoaderTests.swift index c9184f1ef..addaa0ba9 100644 --- a/FlipcashTests/MessageLoaderTests.swift +++ b/FlipcashTests/MessageLoaderTests.swift @@ -23,6 +23,16 @@ struct MessageLoaderTests { ) } + private func makeController(_ database: Database, server: MockConversations) -> ConversationController { + ConversationController( + fetching: server, membership: server, viewerSettings: server, messaging: server, streaming: server, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: UUID(), + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + } + private func message(_ id: UInt64) -> ConversationMessage { ConversationMessage(id: MessageID(value: id), senderID: nil, content: .text("m\(id)"), date: Date(timeIntervalSince1970: TimeInterval(id)), unreadSeq: id) } @@ -101,4 +111,34 @@ struct MessageLoaderTests { let loader = MessageLoader(conversationID: id, controller: makeController(database)) #expect(loader.messages.map(\.id.value) == Array(1...10).map(UInt64.init)) } + + @Test("revealPaging fetches older pages until a linked message outside the local history is stored") + func revealPagingFetchesToTarget() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + try database.upsertConversationMessages((141...200).map { message(UInt64($0)) }, conversationID: id) + let server = MockConversations() + server.olderMessages = (81...140).map { message(UInt64($0)) } + let loader = MessageLoader(conversationID: id, controller: makeController(database, server: server)) + + #expect(loader.reveal(MessageID(value: 100)) == false) + #expect(await loader.revealPaging(MessageID(value: 100))) + #expect(loader.messages.contains { $0.id.value == 100 }) + #expect(server.olderQueries == [MessageID(value: 141)]) + } + + @Test("revealPaging gives up once the server has no older history") + func revealPagingStopsAtHistoryStart() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + try database.upsertConversationMessages((141...200).map { message(UInt64($0)) }, conversationID: id) + let server = MockConversations() + server.olderMessages = [] + let loader = MessageLoader(conversationID: id, controller: makeController(database, server: server)) + + #expect(await loader.revealPaging(MessageID(value: 5)) == false) + #expect(server.olderQueries.count == 1) + } } diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index fd1ff0ad2..58ce1e1b2 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -67,6 +67,24 @@ struct AppRouterCrossStackTests { #expect(router[.tips] == AppRouter.navigationPath(.tipConversation(chatID), profile)) } + @Test("canNavigateOver holds only for the owning tab with no sheet up") + func canNavigateOver_onlyOnOwningTab() { + let router = AppRouter() + let chatID = ConversationID(uuidString: UUID().uuidString)! + let landing = MessageLanding(messageID: MessageID(value: 7), returnTo: chatID) + let destination = AppRouter.Destination.chatMessage(chatID, landing) + + router.activeTabStack = .tips + #expect(router.canNavigateOver(destination)) + + router.present(.give) + #expect(!router.canNavigateOver(destination)) + + router.dismissSheet() + router.activeTabStack = .balance + #expect(!router.canNavigateOver(destination)) + } + @Test("navigateOver from another tab navigates, replacing the owning stack's path") func navigateOver_fromOtherTab_navigates() { let router = AppRouter() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index e7504d275..f29e219c0 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -809,6 +809,7 @@ public final class ChatViewController: UICollectionViewController { public override func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { (cell as? ChatTypingIndicatorCell)?.stopAnimating() + markAttention(cell, isTarget: false) } // MARK: - Keyboard @@ -996,8 +997,20 @@ public final class ChatViewController: UICollectionViewController { attention = (id: stableID, startedAt: now) collectionView.layoutIfNeeded() for cell in bubbleCells(forStableID: stableID) { cell.flashAttention(startedAt: now) } + for indexPath in collectionView.indexPathsForVisibleItems { + guard let cell = collectionView.cellForItem(at: indexPath) else { continue } + markAttention(cell, isTarget: items.indices.contains(indexPath.item) && items[indexPath.item].messageID == stableID) + } + } + + /// Names the jump's target cell for UI tests, which can't observe the flash's animation. + private func markAttention(_ cell: UICollectionViewCell, isTarget: Bool) { + cell.accessibilityIdentifier = isTarget ? Self.attentionTargetIdentifier : nil } + /// The accessibility identifier the row a jump landed on carries; `FlipcashUITests` matches it. + private static let attentionTargetIdentifier = "chat-attention-target" + /// Lights a cell that has just been displayed if it carries the row a jump is pointing at. /// /// A recycled cell loses its `CAAnimation`s, and a jump lands right when the transcript is @@ -1007,7 +1020,11 @@ public final class ChatViewController: UICollectionViewController { private func reattachAttention(to cell: UICollectionViewCell, at indexPath: IndexPath) { guard let attention, items.indices.contains(indexPath.item), - items[indexPath.item].messageID == attention.id else { return } + items[indexPath.item].messageID == attention.id else { + markAttention(cell, isTarget: false) + return + } + markAttention(cell, isTarget: true) guard CACurrentMediaTime() - attention.startedAt < ChatMotion.attentionDuration else { self.attention = nil return diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift new file mode 100644 index 000000000..28c9ac25c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift @@ -0,0 +1,60 @@ +// +// MessageLinkBackChip.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import SwiftUI + +/// The chip a message link leaves under the navigation bar: names the chat the link was tapped in +/// and returns to it. Values from Figma node 11493:212. +public struct MessageLinkBackChip: View { + + private let title: String + private let action: () -> Void + + public init(title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + Text(Self.label(title)) + .font(.appTextSmall) + .lineLimit(1) + } + .foregroundStyle(Color.textMain) + .padding(.horizontal, 14) + .frame(height: Self.height) + .modifier(ChipBackground()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } + + /// What the chip says: where a tap takes the reader. + static func label(_ title: String) -> String { "Back to \(title)" } + + private static let height: CGFloat = 36 +} + +/// Glass on iOS 26, like the composer and the other floating chat surfaces; below that, a flat +/// white fill with the link card's hairline border. +private struct ChipBackground: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content.glassEffect(.regular.interactive(), in: .capsule) + } else { + content + .background(Color.white.opacity(0.12), in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(LinkCardMetrics.borderOpacity), lineWidth: 1)) + } + } +} +#endif diff --git a/FlipcashUITests/Smoke/MessageLinkSmokeTests.swift b/FlipcashUITests/Smoke/MessageLinkSmokeTests.swift new file mode 100644 index 000000000..c7ff9f64e --- /dev/null +++ b/FlipcashUITests/Smoke/MessageLinkSmokeTests.swift @@ -0,0 +1,62 @@ +// +// MessageLinkSmokeTests.swift +// FlipcashUITests +// + +import XCTest + +/// Opens a message link to a message older than the transcript's first page and checks the chat +/// pages back to it, lands on it and highlights it. +/// +/// **Prerequisites:** the `FLIPCASH_UI_TEST_ACCESS_KEY` account (XCTests) has a tip DM with Brandon +/// McAnsh holding more than a page of history, so message `targetSeq` is outside the window a +/// fresh login loads. A link to a chat the account can't open fails the landing assertion. +@MainActor +final class MessageLinkSmokeTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + /// The XCTests ↔ Brandon McAnsh tip DM (contact DMs aren't link targets), as the base64url id a DM link carries. + private static let chatID = "RiQXL4GQUX_EnpuDJs3kv4UORH3QvhQIfioz_QjAFbM" + + /// Far enough below the newest message that the first page doesn't hold it. + private static let targetSeq = 100 + + private var conversation: ConversationUIScreen { ConversationUIScreen(app: app) } + + /// The row a jump landed on; `ChatViewController` names it while it is the attention target. + private var attentionTarget: XCUIElement { + app.cells.matching(identifier: "chat-attention-target").firstMatch + } + + override func setUp() async throws { + try await super.setUp() + // `app.open` relaunches the app when it isn't foremost, and a relaunch replays + // `--ui-testing`, whose nuke logs the account out. Clear them so a relaunch resumes it. + app.launchArguments = [] + // Paging back several pages over the network runs past XCTest's 2-minute default. + executionTimeAllowance = 300 + } + + /// The link opens the chat, pages back to a message it hadn't loaded, and highlights it on + /// screen rather than leaving the transcript at the bottom. + func testMessageLink_toUnloadedMessage_landsAndHighlights() throws { + assertMainScreenReached() + + app.open(URL(string: "flipcash://chat/\(Self.chatID)/m/\(Self.targetSeq)")!) + + XCTAssertTrue( + conversation.messageField.waitForExistence(timeout: 30), + "Expected the link to open the conversation. On screen: [\(visibleText())]" + ) + XCTAssertTrue( + attentionTarget.waitForExistence(timeout: 60), + "Expected the transcript to land on and highlight message \(Self.targetSeq). On screen: [\(visibleText())]" + ) + let onScreen = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: attentionTarget) + XCTAssertEqual( + XCTWaiter().wait(for: [onScreen], timeout: 10), .completed, + "Expected the highlighted message on screen, not scrolled past. Target at \(attentionTarget.frame)" + ) + } +}