From f238069ad512b149e23a4d029ad6263bed0499d8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:31:02 -0400 Subject: [PATCH 1/5] feat(chat): open message links on the linked message `/chat/{id}/m/{seq}` parsed as `.chat(id)` and dropped the message, so a message link opened the chat at the bottom. It now parses to `.chatMessage(id, seq)` and routes through `routeChat` to a sibling destination that carries the message. The transcript lands with the reply jump's scroll-and-flash. When the message isn't stored locally, `MessageLoader.revealPaging` pages older history back from the oldest stored message until it is, capped at 20 pages, so the window stays contiguous. A link to the open chat jumps in place instead of remounting it. A link tapped in another chat shows a back chip under the bar that returns there. It hides when the user drags the transcript or sends. A malformed `/m/` segment falls back to `.chat(id)`. A private group the viewer isn't in still opens its profile. The scan allowlist and the link-card classifier accept the new route; the classifier keeps the group card until the message card lands. --- .../Deep Links/DeepLinkController.swift | 46 +++++++++++-- .../Core/Controllers/Deep Links/Route.swift | 8 +++ .../Navigation/AppRouter+Destination.swift | 7 +- .../AppRouter+DestinationView.swift | 5 ++ .../ChatScreenRepresentable.swift | 24 +++++++ .../Conversation/ConversationScreen.swift | 67 ++++++++++++++++++- .../Conversation/LinkCardClassifier.swift | 2 +- .../Screens/Conversation/MessageLanding.swift | 23 +++++++ .../Screens/Conversation/MessageLoader.swift | 25 +++++++ .../Core/Screens/Main/ScanViewModel.swift | 6 +- FlipcashTests/DeepLinkControllerTests.swift | 67 +++++++++++++++++++ FlipcashTests/MessageLoaderTests.swift | 40 +++++++++++ .../Chat/ChatScreenViewController.swift | 6 ++ .../FlipcashUI/Chat/ChatViewController.swift | 7 ++ .../FlipcashUI/Chat/MessageLinkBackChip.swift | 45 +++++++++++++ 15 files changed, 366 insertions(+), 12 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/MessageLanding.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 6ffd4700b..d297f1ad6 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)", @@ -252,16 +268,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 +439,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 +528,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 +551,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/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index b2e5e6ceb..cce5df73a 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -131,6 +131,12 @@ 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 + /// Fired when the user starts dragging the transcript. + var onUserScroll: () -> Void = {} + /// Fired when the user's own message is appended to the transcript. + var onOwnMessageAppended: () -> Void = {} func makeUIViewController(context: Context) -> ChatScreenViewController { let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator)) @@ -190,6 +196,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen screen.reportsReads = reportsReads + screen.onUserScroll = onUserScroll + perform(messageJump, on: screen, coordinator: context.coordinator) screen.update(items: items) context.coordinator.barHost = barHost context.coordinator.screen = screen @@ -257,13 +265,27 @@ 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 + screen.onUserScroll = onUserScroll + // 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) } + if appendedOwn { + onOwnMessageAppended() + } 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 @@ -386,6 +408,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let overlay: AttachKeyboardOverlay /// What the panel and cards drawn over the keyboard hand back, as of the latest render. var overlayActions: AttachOverlayActions? + /// 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 6abea8a33..0e53c5514 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 @@ -94,6 +97,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; cleared when the user scrolls away or sends. + @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 @@ -445,7 +457,10 @@ struct ConversationScreen: View { mintMediaURL: mintMediaURL, mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer, - transcriptTopInset: anchorHeight > 0 ? anchorHeight + 8 : 0 + transcriptTopInset: anchorHeight > 0 ? anchorHeight + 8 : 0, + messageJump: messageJump, + onUserScroll: hideBackChip, + onOwnMessageAppended: hideBackChip ) } @@ -556,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 } @@ -616,6 +632,14 @@ 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)) { + 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, @@ -820,6 +844,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 @@ -1127,6 +1159,30 @@ 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 hideBackChip() { + if backChipTarget != nil { backChipTarget = nil } + } + private func confirmDelete(_ messageID: MessageID) { guard let conversationID else { return } @@ -1291,8 +1347,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 c7eb887ee..d8d6fc3eb 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..81fcf2321 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/MessageLanding.swift @@ -0,0 +1,23 @@ +// +// 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? +} + +/// 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..030b18c5d 100644 --- a/Flipcash/Core/Screens/Conversation/MessageLoader.swift +++ b/Flipcash/Core/Screens/Conversation/MessageLoader.swift @@ -57,6 +57,31 @@ final class MessageLoader { 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 + } + 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() { 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/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/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 9b0a77406..95b992f9a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -273,6 +273,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onMessageAction = newValue } } + /// Forwards the start of a user drag on the transcript; programmatic scrolls never fire it. + public var onUserScroll: (() -> Void)? { + get { transcript.onUserScroll } + set { transcript.onUserScroll = newValue } + } + /// Forwards a tap on a reply's quote panel, with the quoted row's id. public var onQuoteTap: ((String) -> Void)? { get { transcript.onQuoteTap } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index d87cb6053..db7bf057f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -31,6 +31,9 @@ public final class ChatViewController: UICollectionViewController { /// Fired whenever the transcript's content moves, so an overlay pinned to a row — the edit /// spotlight — can follow it. public var onScroll: (() -> Void)? + /// Fired when the user starts dragging the transcript. Programmatic scrolls — a jump, the + /// opening scroll, keep-at-bottom — never fire it. + public var onUserScroll: (() -> Void)? /// Called with the newest message someone else sent that has been on screen, each time it moves /// past the last one reported. Silent until the opening scroll has landed, and while @@ -1021,6 +1024,10 @@ public final class ChatViewController: UICollectionViewController { } } + public override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + onUserScroll?() + } + public override func scrollViewDidScroll(_ scrollView: UIScrollView) { onScroll?() scheduleReadReport() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift new file mode 100644 index 000000000..05d925a11 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift @@ -0,0 +1,45 @@ +// +// 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(title) + .font(.appTextSmall) + .lineLimit(1) + } + .foregroundStyle(Color.textMain) + .padding(.horizontal, 14) + .frame(height: Self.height) + .background(Color.white.opacity(0.12), in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(LinkCardMetrics.borderOpacity), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to \(title)") + } + + private static let height: CGFloat = 36 +} +#endif From 7f6d0c74b7a69ed5d0f0b5e4bf297db0409c06c8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 15:36:21 -0400 Subject: [PATCH 2/5] test(chat): cover message-link landing with a UI test A link to a message older than the first page landed with the target as the window's first row, half under the top edge, because `reveal` anchored the window on the target itself. It now anchors 12 messages earlier where history allows, and `revealPaging` fetches one more page when the page it stopped on begins at the target. `MessageLinkSmokeTests` opens `/chat/{id}/m/100` in the XCTests tip DM, whose first page holds 251-300, and waits for the highlighted row to be on screen. The row a jump lands on carries the `chat-attention-target` accessibility identifier while the flash runs, so the test can find it. --- .../Screens/Conversation/MessageLoader.swift | 14 ++++- .../Chat/MessageLoaderRevealTests.swift | 9 +++ .../FlipcashUI/Chat/ChatViewController.swift | 19 +++++- .../Smoke/MessageLinkSmokeTests.swift | 62 +++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 FlipcashUITests/Smoke/MessageLinkSmokeTests.swift diff --git a/Flipcash/Core/Screens/Conversation/MessageLoader.swift b/Flipcash/Core/Screens/Conversation/MessageLoader.swift index 030b18c5d..7baa36ca7 100644 --- a/Flipcash/Core/Screens/Conversation/MessageLoader.swift +++ b/Flipcash/Core/Screens/Conversation/MessageLoader.swift @@ -47,13 +47,15 @@ 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 } @@ -76,6 +78,12 @@ final class MessageLoader { 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 } @@ -115,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/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/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index db7bf057f..7f7fc60e6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -791,6 +791,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 @@ -972,8 +973,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 @@ -983,7 +996,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/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)" + ) + } +} From 2d7ae6eaf09beb2ba257a40aca30547833a7a696 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 00:16:53 -0400 Subject: [PATCH 3/5] fix(chat): draw the message link back chip in glass It used a flat 12% white fill. On iOS 26 it now takes the same interactive glass as the composer; older iOS keeps the fill and hairline border. --- .../FlipcashUI/Chat/MessageLinkBackChip.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift index 05d925a11..45120b79f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLinkBackChip.swift @@ -32,8 +32,7 @@ public struct MessageLinkBackChip: View { .foregroundStyle(Color.textMain) .padding(.horizontal, 14) .frame(height: Self.height) - .background(Color.white.opacity(0.12), in: Capsule()) - .overlay(Capsule().strokeBorder(Color.white.opacity(LinkCardMetrics.borderOpacity), lineWidth: 1)) + .modifier(ChipBackground()) .contentShape(Capsule()) } .buttonStyle(.plain) @@ -42,4 +41,18 @@ public struct MessageLinkBackChip: View { 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 From ee69b478bee2545aa1a4fafeb045baac94b4cf02 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 00:27:26 -0400 Subject: [PATCH 4/5] fix(chat): keep the message-link back chip until it is tapped or the chat closes Dragging the transcript or sending a message no longer hides the chip, matching Android. The drag and own-message hooks existed only for the chip, so they go with it. --- .../Screens/Conversation/ChatScreenRepresentable.swift | 9 --------- .../Core/Screens/Conversation/ConversationScreen.swift | 10 ++-------- .../FlipcashUI/Chat/ChatScreenViewController.swift | 6 ------ .../Sources/FlipcashUI/Chat/ChatViewController.swift | 7 ------- 4 files changed, 2 insertions(+), 30 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index d4007fa52..d10f1ab21 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -133,10 +133,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { 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 - /// Fired when the user starts dragging the transcript. - var onUserScroll: () -> Void = {} - /// Fired when the user's own message is appended to the transcript. - var onOwnMessageAppended: () -> Void = {} func makeUIViewController(context: Context) -> ChatScreenViewController { let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator)) @@ -196,7 +192,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen screen.reportsReads = reportsReads - screen.onUserScroll = onUserScroll perform(messageJump, on: screen, coordinator: context.coordinator) screen.update(items: items) context.coordinator.barHost = barHost @@ -265,7 +260,6 @@ 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 - screen.onUserScroll = onUserScroll // 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) @@ -273,9 +267,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { if appendedOwn, !wasAtBottom { screen.scrollToBottom(animated: true) } - if appendedOwn { - onOwnMessageAppended() - } context.coordinator.lastMessageID = newLastMessageID } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index f846dd846..b53cf280b 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -103,7 +103,7 @@ struct ConversationScreen: View { /// 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; cleared when the user scrolls away or sends. + /// 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. @@ -459,9 +459,7 @@ struct ConversationScreen: View { mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer, transcriptTopInset: anchorHeight > 0 ? anchorHeight + 8 : 0, - messageJump: messageJump, - onUserScroll: hideBackChip, - onOwnMessageAppended: hideBackChip + messageJump: messageJump ) } @@ -1181,10 +1179,6 @@ struct ConversationScreen: View { land(on: landing.messageID) } - private func hideBackChip() { - if backChipTarget != nil { backChipTarget = nil } - } - private func confirmDelete(_ messageID: MessageID) { guard let conversationID else { return } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 95b992f9a..9b0a77406 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -273,12 +273,6 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onMessageAction = newValue } } - /// Forwards the start of a user drag on the transcript; programmatic scrolls never fire it. - public var onUserScroll: (() -> Void)? { - get { transcript.onUserScroll } - set { transcript.onUserScroll = newValue } - } - /// Forwards a tap on a reply's quote panel, with the quoted row's id. public var onQuoteTap: ((String) -> Void)? { get { transcript.onQuoteTap } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index af1823877..5eb33ac85 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -31,9 +31,6 @@ public final class ChatViewController: UICollectionViewController { /// Fired whenever the transcript's content moves, so an overlay pinned to a row — the edit /// spotlight — can follow it. public var onScroll: (() -> Void)? - /// Fired when the user starts dragging the transcript. Programmatic scrolls — a jump, the - /// opening scroll, keep-at-bottom — never fire it. - public var onUserScroll: (() -> Void)? /// Called with the newest message someone else sent that has been on screen, each time it moves /// past the last one reported. Silent until the opening scroll has landed, and while @@ -1051,10 +1048,6 @@ public final class ChatViewController: UICollectionViewController { } } - public override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - onUserScroll?() - } - public override func scrollViewDidScroll(_ scrollView: UIScrollView) { onScroll?() scheduleReadReport() From 7aedfca1e75674aa215b3a6f005ee58e848203f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 08:06:00 -0400 Subject: [PATCH 5/5] fix(chat): return from a message link to the chat as the reader left it A message link tapped in a chat replaced the whole chat stack, so the back chip reopened the source chat fresh at the bottom. The landed chat is now pushed over the source when that chat is the visible screen, and the chip pops back to it. A link that can't go on top (another tab or a sheet is up) still navigates, and the chip still reopens the chat. --- .../Deep Links/DeepLinkController.swift | 10 +++++++++- Flipcash/Core/Navigation/AppRouter.swift | 8 +++++++- .../Conversation/ConversationScreen.swift | 6 +++++- .../Screens/Conversation/MessageLanding.swift | 3 +++ .../Navigation/AppRouterCrossStackTests.swift | 18 ++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index d297f1ad6..77d8f16b3 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -260,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. 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/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index b53cf280b..6f3acfe0c 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -633,7 +633,11 @@ struct ConversationScreen: View { VStack(spacing: 8) { if let backChipTarget { MessageLinkBackChip(title: conversationController.displayName(forConversationID: backChipTarget)) { - router.navigate(to: .tipConversation(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) diff --git a/Flipcash/Core/Screens/Conversation/MessageLanding.swift b/Flipcash/Core/Screens/Conversation/MessageLanding.swift index 81fcf2321..46e276693 100644 --- a/Flipcash/Core/Screens/Conversation/MessageLanding.swift +++ b/Flipcash/Core/Screens/Conversation/MessageLanding.swift @@ -12,6 +12,9 @@ nonisolated struct MessageLanding: Hashable, Sendable { 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 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()