From 550fcf9bd4c04cd99b71e2f06259c5cf7d36d781 Mon Sep 17 00:00:00 2001 From: agent-team Date: Tue, 13 Oct 2026 22:43:18 -0400 Subject: [PATCH 01/19] feat(chat): add jump-to-bottom button with unread badge to the transcript The badge counts inbound messages after the viewer's read pointer, read through the same helper the unread divider and advanceReadPointer use. The button shows once the opening scroll has landed and the reader is off the bottom; tapping it scrolls through ChatViewController.scrollToBottom, jumping past 100 rows. --- .../Controllers/ConversationController.swift | 33 +++- .../ChatScreenRepresentable.swift | 4 + .../Conversation/ConversationScreen.swift | 1 + .../Models/Conversation/UnreadBadgeText.swift | 20 +++ .../UnreadBadgeTextTests.swift | 25 +++ .../Chat/UnreadBelowCountTests.swift | 95 ++++++++++ FlipcashTests/Chat/UnreadDividerTests.swift | 42 +---- .../TestSupport/UnreadFixtures.swift | 60 +++++++ .../Chat/ChatScreenViewController.swift | 52 ++++++ .../FlipcashUI/Chat/ChatViewController.swift | 55 +++++- .../FlipcashUI/Chat/ComposerFade.swift | 2 +- .../FlipcashUI/Chat/JumpToBottomButton.swift | 170 ++++++++++++++++++ .../Sources/FlipcashUI/Theme/Colors.swift | 2 + 13 files changed, 513 insertions(+), 48 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBadgeText.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/UnreadBadgeTextTests.swift create mode 100644 FlipcashTests/Chat/UnreadBelowCountTests.swift create mode 100644 FlipcashTests/TestSupport/UnreadFixtures.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 60094bb3d..2f8bd3ac2 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1911,13 +1911,13 @@ final class ConversationController { /// pointer — see ``UnreadBoundary``. func unreadBoundary(for conversationID: ConversationID) -> UnreadBoundary { UnreadBoundary.resolve( - readPointer: store.selfReadPointer(for: conversationID, selfUserID: selfUserID), + readPointer: readThrough(for: conversationID), firstInbound: { readPointer in ((try? database.firstInboundMessageID(conversationID: conversationID, after: readPointer, excludingSender: selfUserID)) ?? nil) .map(MessageID.init(value:)) }, unreadCount: { readPointer in - (try? database.inboundMessageCount(conversationID: conversationID, after: readPointer, excludingSender: selfUserID)) ?? 0 + inboundMessageCount(after: readPointer, in: conversationID) }, hasStored: { readThrough in newestPersistedMessageID(through: readThrough, in: conversationID) != nil @@ -1925,6 +1925,30 @@ final class ConversationController { ) } + /// The newest inbound id the viewer has seen: the stored READ pointer or the unsynced one ahead of + /// it. The one value the unread divider, the jump-to-bottom badge, and + /// ``advanceReadPointer(to:in:)`` all read, so they can't disagree. + private func readThrough(for conversationID: ConversationID) -> MessageID? { + [ + store.selfReadPointer(for: conversationID, selfUserID: selfUserID), + store.unsyncedSelfReadPointers[conversationID], + ].compactMap { $0 }.max() + } + + /// How many stored, non-deleted messages someone else sent after `messageID`. + func inboundMessageCount(after messageID: MessageID, in conversationID: ConversationID) -> Int { + (try? database.inboundMessageCount(conversationID: conversationID, after: messageID, excludingSender: selfUserID)) ?? 0 + } + + /// Inbound messages the viewer hasn't seen: those after ``readThrough(for:)``, or 0 when the + /// pointer is unknown (as ``unreadBoundary(for:)`` treats it). Observes the read pointer and + /// `messageRevision`, so it re-evaluates when either the pointer moves or a message lands. + func unreadBelowCount(in conversationID: ConversationID) -> Int { + _ = messageRevision // observe: re-count when a confirmed DB write lands + guard let readThrough = readThrough(for: conversationID) else { return 0 } + return inboundMessageCount(after: readThrough, in: conversationID) + } + /// The newest stored message id at or below `messageID`, or nil when none is stored. func newestPersistedMessageID(through messageID: MessageID, in conversationID: ConversationID) -> MessageID? { ((try? database.newestMessageID(conversationID: conversationID, through: messageID)) ?? nil).map(MessageID.init(value:)) @@ -2523,10 +2547,7 @@ final class ConversationController { // cannot read has nothing to mark. guard canAdvancePointer(conversationID) else { return } // A chat not in the feed yet has no member row to hold the pointer, only the unsynced entry. - let previous = [ - store.selfReadPointer(for: conversationID, selfUserID: selfUserID), - store.unsyncedSelfReadPointers[conversationID], - ].compactMap { $0 }.max() + let previous = readThrough(for: conversationID) if let previous, messageID <= previous { return } store.advanceSelfReadPointer(to: messageID, in: conversationID, selfUserID: selfUserID) // The pointer only moves forward, so the window it just crossed holds exactly the messages the diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index eec53abbd..cafbb7081 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -58,6 +58,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onMessagesSeen: (MessageID) -> Void /// Whether rows on screen count as read right now. let reportsReads: Bool + /// Inbound messages after the viewer's read pointer, for the jump-to-bottom badge. + let unreadBelowCount: Int /// Fired when a reaction pill is tapped: the row's stable id and the toggled emoji. let onReactionTap: (String, String) -> Void /// Fired when a reaction pill is long-pressed: the row's stable id and emoji, to open the @@ -189,6 +191,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCancelEdit = { [composer] in composer.endEditing() } screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen + screen.unreadBelowCount = unreadBelowCount screen.reportsReads = reportsReads screen.update(items: items) context.coordinator.barHost = barHost @@ -239,6 +242,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCancelEdit = { [composer] in composer.endEditing() } screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen + screen.unreadBelowCount = unreadBelowCount screen.reportsReads = reportsReads // The backdrop is raised from the menu action itself (see `keyboardFollowing`) because it // has to claim the menu's blur before the dismissal fades it; it comes down here, whichever diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 2481d58dd..353df5c00 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -407,6 +407,7 @@ struct ConversationScreen: View { onQuoteTap: jumpToQuote, onMessagesSeen: markSeen, reportsReads: reportsReads(gate: gate), + unreadBelowCount: conversationID.map { conversationController.unreadBelowCount(in: $0) } ?? 0, onReactionTap: toggleReaction, onReactionLongPress: openReactors, onReactionAdd: openReactionPicker, diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBadgeText.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBadgeText.swift new file mode 100644 index 000000000..8e910b740 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBadgeText.swift @@ -0,0 +1,20 @@ +// +// UnreadBadgeText.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The text on the jump-to-bottom button's unread badge. Android caps at the same value. +public enum UnreadBadgeText { + /// The largest count shown as a number; anything above reads "99+". + public static let cap = 99 + + /// The badge text for `count`, or nil when the badge is hidden (nothing unread). + public static func text(for count: Int) -> String? { + guard count > 0 else { return nil } + return count > cap ? "\(cap)+" : "\(count)" + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UnreadBadgeTextTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UnreadBadgeTextTests.swift new file mode 100644 index 000000000..ebc7eccae --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/UnreadBadgeTextTests.swift @@ -0,0 +1,25 @@ +// +// UnreadBadgeTextTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +@testable import FlipcashCore + +@Suite("Unread badge text") +struct UnreadBadgeTextTests { + + @Test("counts render as numbers up to the cap, then 99+", arguments: [ + (150, "99+"), (100, "99+"), (99, "99"), (1, "1"), + ]) + func text_capsAtNinetyNine(_ count: Int, _ expected: String) { + #expect(UnreadBadgeText.text(for: count) == expected) + } + + @Test("zero hides the badge") + func text_zeroIsHidden() { + #expect(UnreadBadgeText.text(for: 0) == nil) + } +} diff --git a/FlipcashTests/Chat/UnreadBelowCountTests.swift b/FlipcashTests/Chat/UnreadBelowCountTests.swift new file mode 100644 index 000000000..449e30929 --- /dev/null +++ b/FlipcashTests/Chat/UnreadBelowCountTests.swift @@ -0,0 +1,95 @@ +// +// UnreadBelowCountTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@MainActor +@Suite("Unread below count") +struct UnreadBelowCountTests { + + private let id = ConversationID.test(1) + + private func message(_ id: UInt64, fromSelf: Bool) -> ConversationMessage { + UnreadFixtures.message(id, fromSelf: fromSelf) + } + + @Test("starts equal to the unread divider's count") + func initialCount_matchesDividerCount() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: [message(1, fromSelf: false), message(2, fromSelf: false), message(3, fromSelf: true), message(4, fromSelf: false)], + pointer: 1, database: database + ) + + #expect(controller.unreadBelowCount(in: id) == 2) + #expect(controller.unreadBelowCount(in: id) == controller.unreadBoundary(for: id).count) + } + + @Test("advancing the read pointer to a seen inbound id decrements the count") + func advanceReadPointer_decrements() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: 1, database: database + ) + + controller.advanceReadPointer(to: MessageID(value: 2), in: id) + + #expect(controller.unreadBelowCount(in: id) == 2) + } + + @Test("an inbound message with a higher id increments the count") + func inboundArrival_increments() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: [message(1, fromSelf: false), message(2, fromSelf: false)], + pointer: 1, database: database + ) + #expect(controller.unreadBelowCount(in: id) == 1) + + try database.upsertConversationMessages([message(3, fromSelf: false)], conversationID: id) + + #expect(controller.unreadBelowCount(in: id) == 2) + } + + @Test("an own message does not count, and seeing the newest inbound clears the count") + func ownSend_doesNotCount_andSeeingNewestInboundClears() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: [message(1, fromSelf: false), message(2, fromSelf: false), message(3, fromSelf: false)], + pointer: 1, database: database + ) + #expect(controller.unreadBelowCount(in: id) == 2) + + try database.upsertConversationMessages([message(4, fromSelf: true)], conversationID: id) + #expect(controller.unreadBelowCount(in: id) == 2) + + // What the landed scroll to the bottom reports: the newest inbound row now on screen. + controller.advanceReadPointer(to: MessageID(value: 3), in: id) + #expect(controller.unreadBelowCount(in: id) == 0) + } + + @Test("an unknown read pointer counts nothing, as the divider does") + func unknownPointer_countsNothing() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: [message(1, fromSelf: false), message(2, fromSelf: false)], + pointer: nil, database: database + ) + + #expect(controller.unreadBelowCount(in: id) == 0) + } +} diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index 8fe9c7378..c295bd65a 100644 --- a/FlipcashTests/Chat/UnreadDividerTests.swift +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -16,8 +16,8 @@ import FlipcashUI @Suite("Unread divider") struct UnreadDividerTests { - private static let me = UUID() - private static let them = UUID() + private static let me = UnreadFixtures.me + private static let them = UnreadFixtures.them /// One row of the shared-cases table in the design spec. Android encodes the same table. struct SharedCase: CustomTestStringConvertible, Sendable { @@ -75,44 +75,12 @@ struct UnreadDividerTests { ), ] - private let base = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 1_000_000)) - .addingTimeInterval(9 * 60 * 60) - private func message(_ id: UInt64, fromSelf: Bool, deleted: Bool = false, after offset: TimeInterval? = nil) -> ConversationMessage { - let date = base.addingTimeInterval(offset ?? TimeInterval(id) * 60) - return ConversationMessage( - id: MessageID(value: id), - senderID: fromSelf ? Self.me : Self.them, - content: deleted ? .deleted(.init(deletedBy: Self.them, deletedAt: date)) : .text("m\(id)"), - date: date, - unreadSeq: id - ) + UnreadFixtures.message(id, fromSelf: fromSelf, deleted: deleted, after: offset) } - /// A controller hydrated from a database holding `messages` and, when `pointer` is non-nil, the - /// viewer's own member row reading through it. - private func hydratedController( - messages: [ConversationMessage], - pointer: UInt64?, - database: Database - ) async throws -> ConversationController { - var members = [ConversationMember(userID: Self.them, displayName: "Them")] - if let pointer { - members.append(ConversationMember(userID: Self.me, displayName: "", readPointer: MessageID(value: pointer))) - } - try database.upsertConversation( - Conversation(id: .test(1), members: members, lastMessage: nil, lastActivity: base) - ) - try database.upsertConversationMessages(messages, conversationID: .test(1)) - let mock = MockConversations() - let controller = ConversationController( - fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, - contactNaming: MockDMContactNaming(), - database: database, - owner: .generate()!, selfUserID: Self.me - ) - await controller.hydrateFromDatabase() - return controller + private func hydratedController(messages: [ConversationMessage], pointer: UInt64?, database: Database) async throws -> ConversationController { + try await UnreadFixtures.hydratedController(messages: messages, pointer: pointer, database: database) } /// The id of the message row directly under the divider, or nil when there is no divider. diff --git a/FlipcashTests/TestSupport/UnreadFixtures.swift b/FlipcashTests/TestSupport/UnreadFixtures.swift new file mode 100644 index 000000000..1dd4c5693 --- /dev/null +++ b/FlipcashTests/TestSupport/UnreadFixtures.swift @@ -0,0 +1,60 @@ +// +// UnreadFixtures.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +/// A controller over a real temp database with a viewer, a counterpart, and stored messages — shared +/// by the suites that read the viewer's unread state. +@MainActor +enum UnreadFixtures { + + static let me = UUID() + static let them = UUID() + + static let base = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 1_000_000)) + .addingTimeInterval(9 * 60 * 60) + + static func message(_ id: UInt64, fromSelf: Bool, deleted: Bool = false, after offset: TimeInterval? = nil) -> ConversationMessage { + let date = base.addingTimeInterval(offset ?? TimeInterval(id) * 60) + return ConversationMessage( + id: MessageID(value: id), + senderID: fromSelf ? me : them, + content: deleted ? .deleted(.init(deletedBy: them, deletedAt: date)) : .text("m\(id)"), + date: date, + unreadSeq: id + ) + } + + /// A controller hydrated from a database holding `messages` and, when `pointer` is non-nil, the + /// viewer's own member row reading through it. + static func hydratedController( + messages: [ConversationMessage], + pointer: UInt64?, + database: Database + ) async throws -> ConversationController { + var members = [ConversationMember(userID: them, displayName: "Them")] + if let pointer { + members.append(ConversationMember(userID: me, displayName: "", readPointer: MessageID(value: pointer))) + } + try database.upsertConversation( + Conversation(id: .test(1), members: members, lastMessage: nil, lastActivity: base) + ) + try database.upsertConversationMessages(messages, conversationID: .test(1)) + let mock = MockConversations() + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: me + ) + await controller.hydrateFromDatabase() + return controller + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 9b0a77406..dd6c730a7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -109,6 +109,8 @@ public final class ChatScreenViewController: UIViewController { private let backdrop = MessageBackdrop() /// The blur over a transcript the user is not allowed to read. See `TranscriptBlur`. private let transcriptBlur = TranscriptBlur() + private let jumpButton = JumpToBottomButton() + private let jumpHaze = JumpHazeView() /// The decorative shapes the blur softens when there is no transcript to soften. See /// `GatePreviewPlaceholder`. private let gatePlaceholder = GatePreviewPlaceholder() @@ -382,6 +384,7 @@ public final class ChatScreenViewController: UIViewController { // Under the blur, so the shapes are only ever seen through it. gatePlaceholder.install(in: view, below: transcriptBlur.view) + installJumpButton() barClipHeightConstraint = constraints.clipHeight keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom, keyboardEdgeConstraint: constraints.keyboardEdge) keyboardFloor.restingDrop = barRestingDrop @@ -916,6 +919,55 @@ public final class ChatScreenViewController: UIViewController { } public func update(items: [ChatItem]) { transcript.update(items: items) } + + /// Inbound messages the viewer hasn't seen, shown on the jump-to-bottom button's badge. Zero + /// hides the badge; the button itself shows whenever the reader is off the bottom. + public var unreadBelowCount: Int = 0 { + didSet { jumpButton.setUnreadCount(unreadBelowCount) } + } + + /// Gap between the jump button and the composer's top edge. + private static let jumpButtonGap: CGFloat = 20 + private static let jumpButtonInset: CGFloat = 16 + private static let jumpHazeHeight: CGFloat = 120 + /// Scale the button sits at while hidden. + private static let jumpHiddenScale: CGFloat = 0.8 + + /// Floats the jump button over the transcript, above the composer's clip rather than inside it + /// (the clip toggles `clipsToBounds`), with its haze behind. + private func installJumpButton() { + jumpHaze.translatesAutoresizingMaskIntoConstraints = false + jumpButton.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(jumpHaze, belowSubview: barClip) + view.insertSubview(jumpButton, belowSubview: barClip) + NSLayoutConstraint.activate([ + jumpHaze.leadingAnchor.constraint(equalTo: view.leadingAnchor), + jumpHaze.trailingAnchor.constraint(equalTo: view.trailingAnchor), + jumpHaze.bottomAnchor.constraint(equalTo: barClip.topAnchor), + jumpHaze.heightAnchor.constraint(equalToConstant: Self.jumpHazeHeight), + jumpButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.jumpButtonInset), + jumpButton.bottomAnchor.constraint(equalTo: barClip.topAnchor, constant: -Self.jumpButtonGap), + ]) + jumpButton.addAction(UIAction { [weak self] _ in self?.transcript.scrollToBottomFromJumpButton() }, for: .touchUpInside) + setJumpButton(visible: false, animated: false) + transcript.onJumpButtonVisibilityChange = { [weak self] visible in + self?.setJumpButton(visible: visible, animated: true) + } + } + + /// Fades and scales the button and haze in or out. Hidden, the button also stops taking + /// touches; alpha 0 alone would still be tappable. + private func setJumpButton(visible: Bool, animated: Bool) { + let scale = visible ? 1 : Self.jumpHiddenScale + let apply = { + self.jumpButton.alpha = visible ? 1 : 0 + self.jumpHaze.alpha = visible ? 1 : 0 + self.jumpButton.transform = CGAffineTransform(scaleX: scale, y: scale) + } + jumpButton.isUserInteractionEnabled = visible + guard animated else { apply(); return } + ChatMotion.reflow.animate(apply) + } public func scrollToBottom(animated: Bool = true) { transcript.scrollToBottom(animated: animated) } /// Whether the transcript sits at its newest message — see ``ChatViewController/isAtBottom``. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index ce7e138ee..f847fb2c5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -195,7 +195,9 @@ public final class ChatViewController: UICollectionViewController { /// Whether the user last left the transcript at the bottom. Updated only on user-driven /// scrolls, so content settling or the keyboard can't flip it — it's the gate for following /// the keyboard (an inset change) without yanking a reader who scrolled up. - private var wasAtBottom = true + private var wasAtBottom = true { + didSet { publishJumpButtonState() } + } /// True until the first non-empty content has been scrolled to the bottom. The open is /// deferred to `viewDidLayoutSubviews` so it runs once the collection view has real bounds. private var needsInitialScroll = false @@ -207,7 +209,15 @@ public final class ChatViewController: UICollectionViewController { /// Set once the opening scroll has landed. Until then the transcript sits wherever the first /// layout put it — at the bottom, for a chat that is about to move up to its unread divider — /// and reporting that frame would mark read everything the divider is there to point at. - private var hasPositioned = false + private var hasPositioned = false { + didSet { publishJumpButtonState() } + } + /// Rows past the last visible one beyond which a tap on the jump button jumps instead of animating. + static let jumpAnimationRowLimit = 100 + /// Fires when the jump-to-bottom button should appear or go: the opening scroll has landed and the + /// reader is outside ``bottomThreshold`` of the newest message. + var onJumpButtonVisibilityChange: ((Bool) -> Void)? + private var isJumpButtonVisible = false /// What this visit has reported read so far. private var readProgress = ReadProgress() /// Whether a read report is queued for the next main-queue turn, so a burst of scroll ticks @@ -924,6 +934,7 @@ public final class ChatViewController: UICollectionViewController { needsInitialScroll = false scrollToRow(id: target, animated: false) // The jump lands synchronously, so it is the opening position as soon as it runs. + wasAtBottom = isNearBottomByGeometry hasPositioned = true return true } @@ -934,13 +945,18 @@ public final class ChatViewController: UICollectionViewController { guard let index = items.firstIndex(where: { $0.messageID == id }) else { return } let indexPath = IndexPath(item: index, section: 0) positionClaim += 1 + let claim = positionClaim guard animated else { collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) + wasAtBottom = isNearBottomByGeometry flashAttention(forStableID: id) return } ChatMotion.scroll.animate { self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) + } completion: { _ in + guard self.positionClaim == claim else { return } + self.wasAtBottom = self.isNearBottomByGeometry } // Alongside the scroll rather than after it: the flash's rise is shorter than the scroll's // settle, so the row is already lit when it arrives and there is no beat where the transcript @@ -1008,6 +1024,9 @@ public final class ChatViewController: UICollectionViewController { edge: .bottom ) let claim = positionClaim + // The reader is headed to the bottom, and these programmatic moves don't pass through the + // drag tracking that normally sets this. + wasAtBottom = true guard animated else { chatLayout.restoreContentOffset(with: snapshot) // The first restore positions by the estimate; once the bottom cells self-size, re-anchor @@ -1015,6 +1034,7 @@ public final class ChatViewController: UICollectionViewController { DispatchQueue.main.async { [weak self] in guard let self, positionClaim == claim else { return } chatLayout.restoreContentOffset(with: snapshot) + wasAtBottom = true } return } @@ -1028,17 +1048,42 @@ public final class ChatViewController: UICollectionViewController { // Lock to the exact bottom edge once the animation lands (the estimate may have moved). guard self.positionClaim == claim else { return } self.chatLayout.restoreContentOffset(with: snapshot) + self.wasAtBottom = true } } + /// Whether the offset sits within ``bottomThreshold`` of the bottom edge. Geometry only; unlike + /// ``wasAtBottom`` it reads the live offset, so it is right after a programmatic move. + private var isNearBottomByGeometry: Bool { + let maxOffset = chatLayout.collectionViewContentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + return maxOffset - collectionView.contentOffset.y < Self.bottomThreshold + } + + private func publishJumpButtonState() { + let visible = hasPositioned && !wasAtBottom + guard visible != isJumpButtonVisible else { return } + isJumpButtonVisible = visible + onJumpButtonVisibilityChange?(visible) + } + + /// Scrolls to the newest message for the jump button: a jump when more than + /// ``jumpAnimationRowLimit`` rows lie below the last visible one, an animated scroll otherwise. + func scrollToBottomFromJumpButton() { + guard !items.isEmpty else { return } + let lastVisible = (chatLayout.layoutAttributesForElements(in: chatLayout.visibleBounds) ?? []) + .filter { $0.representedElementCategory == .cell } + .map(\.indexPath.item) + .max() ?? 0 + scrollToBottom(animated: items.count - 1 - lastVisible <= Self.jumpAnimationRowLimit) + } + public override func scrollViewDidScroll(_ scrollView: UIScrollView) { onScroll?() scheduleReadReport() // Track "at the bottom" only from real user scrolling, so an inset change (keyboard) or // content settling doesn't flip it. if scrollView.isDragging || scrollView.isDecelerating { - let maxOffset = chatLayout.collectionViewContentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom - wasAtBottom = maxOffset - scrollView.contentOffset.y < Self.bottomThreshold + wasAtBottom = isNearBottomByGeometry } // Don't paginate while a batch update animates, or before the opening scroll-to-bottom has // run — on open the content sits at the top for a beat, which would otherwise fire a stray @@ -1065,6 +1110,8 @@ public final class ChatViewController: UICollectionViewController { DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in guard let self, !hasPositioned else { return } + // An open placed at the unread divider is off the bottom, and no drag has said so. + wasAtBottom = isNearBottomByGeometry hasPositioned = true scheduleReadReport() } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift index 22dc37a72..6b9f7d373 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift @@ -65,7 +65,7 @@ final class ComposerFadeView: UIView { } /// A view backed by a top-to-bottom gradient layer. -private final class GradientView: UIView { +final class GradientView: UIView { override class var layerClass: AnyClass { CAGradientLayer.self } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift new file mode 100644 index 000000000..1a5cc4dc7 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift @@ -0,0 +1,170 @@ +// +// JumpToBottomButton.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import SwiftUI +import UIKit +import FlipcashCore + +/// The gradient behind the jump button: the chat background rising from nothing to nearly opaque +/// toward the composer, so the button reads over any bubble beneath it. +@MainActor +final class JumpHazeView: UIView { + + private let ramp = GradientView() + + init() { + super.init(frame: .zero) + isUserInteractionEnabled = false + addSubview(ramp) + let base = UIColor(Color.backgroundMain) + ramp.set( + colors: [0, 0.75, 0.95].map { base.withAlphaComponent($0) }, + locations: [0, 0.5, 1] + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + ramp.frame = bounds + } +} + +/// A 44pt glass circle with a chevron and an unread badge that scrolls the transcript to its newest +/// message. White 12% over the chat's frosting, a hairline in the row-separator white, and a soft +/// shadow. +@MainActor +final class JumpToBottomButton: UIControl { + + static let diameter: CGFloat = 44 + + private let blur = UIVisualEffectView() + private let badge = BadgeView() + private var blurStrength: UIViewPropertyAnimator? + + init() { + super.init(frame: .zero) + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.25 + layer.shadowRadius = 8 + layer.shadowOffset = CGSize(width: 0, height: 2) + + let disc = UIView() + disc.translatesAutoresizingMaskIntoConstraints = false + disc.isUserInteractionEnabled = false + disc.clipsToBounds = true + disc.layer.cornerRadius = Self.diameter / 2 + disc.layer.borderWidth = 0.5 + disc.layer.borderColor = UIColor(Color.rowSeparator).cgColor + addSubview(disc) + + let tint = UIView() + tint.translatesAutoresizingMaskIntoConstraints = false + tint.backgroundColor = UIColor.white.withAlphaComponent(0.12) + blur.translatesAutoresizingMaskIntoConstraints = false + disc.addSubview(blur) + disc.addSubview(tint) + blurStrength = ChatBackdrop.frost(blur) + + let chevron = UIImageView(image: UIImage( + systemName: "chevron.down", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) + )) + chevron.translatesAutoresizingMaskIntoConstraints = false + chevron.tintColor = .white + chevron.contentMode = .center + chevron.isUserInteractionEnabled = false + addSubview(chevron) + + badge.translatesAutoresizingMaskIntoConstraints = false + addSubview(badge) + + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: Self.diameter), + heightAnchor.constraint(equalToConstant: Self.diameter), + disc.topAnchor.constraint(equalTo: topAnchor), + disc.bottomAnchor.constraint(equalTo: bottomAnchor), + disc.leadingAnchor.constraint(equalTo: leadingAnchor), + disc.trailingAnchor.constraint(equalTo: trailingAnchor), + blur.topAnchor.constraint(equalTo: disc.topAnchor), + blur.bottomAnchor.constraint(equalTo: disc.bottomAnchor), + blur.leadingAnchor.constraint(equalTo: disc.leadingAnchor), + blur.trailingAnchor.constraint(equalTo: disc.trailingAnchor), + tint.topAnchor.constraint(equalTo: disc.topAnchor), + tint.bottomAnchor.constraint(equalTo: disc.bottomAnchor), + tint.leadingAnchor.constraint(equalTo: disc.leadingAnchor), + tint.trailingAnchor.constraint(equalTo: disc.trailingAnchor), + chevron.centerXAnchor.constraint(equalTo: centerXAnchor), + chevron.centerYAnchor.constraint(equalTo: centerYAnchor), + // The pill's centre sits near the circle's top-trailing corner, pulled in along the arc. + badge.centerXAnchor.constraint(equalTo: trailingAnchor, constant: -4), + badge.centerYAnchor.constraint(equalTo: topAnchor, constant: 4), + ]) + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityLabel = "Jump to latest message" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + isolated deinit { + blurStrength?.stopAnimation(true) + } + + /// Sets the badge from `count`; the badge hides at zero. + func setUnreadCount(_ count: Int) { + let text = UnreadBadgeText.text(for: count) + badge.isHidden = text == nil + badge.text = text + accessibilityValue = text + } + + /// The pill: blue fill, white semibold 11, and a 2pt ring in the chat background that separates + /// it from the circle. + private final class BadgeView: UIView { + private let label = UILabel() + + var text: String? { + get { label.text } + set { label.text = newValue } + } + + init() { + super.init(frame: .zero) + isUserInteractionEnabled = false + isHidden = true + backgroundColor = UIColor(Color.badgeFocus) + layer.borderWidth = 2 + layer.borderColor = UIColor(Color.backgroundMain).cgColor + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .white + addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + widthAnchor.constraint(greaterThanOrEqualTo: heightAnchor), + heightAnchor.constraint(equalToConstant: 20), + label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 5), + label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -5), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index 7c9020330..323161512 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -43,6 +43,8 @@ extension ShapeStyle where Self == Color { public static var coverPlaceholder: Color { Color(r: 50, g: 50, b: 52) } public static var unreadIndicator: Color { Color(r: 10, g: 132, b: 255) } + /// `#0A84FF` — the badge/focus token, used by the jump-to-bottom unread badge. Same value as `unreadIndicator`, named for its Figma token. + public static var badgeFocus: Color { Color(r: 10, g: 132, b: 255) } public static var checkmarkBackground: Color { Color.white } From 6fdf9ee84360c1bb4bf975e18c89417d1b7a6656 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:17:16 -0400 Subject: [PATCH 02/19] fix(chat): bump messageRevision after a media send so the unread count refreshes --- Flipcash/Core/Controllers/ConversationController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 2f8bd3ac2..b71ed5eb1 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1605,7 +1605,7 @@ final class ConversationController { /// Persist operations that write confirmed messages — the ones that must bump `messageRevision`. private static let messageWriteOperations: Set = [ "upsert-messages", "apply-chat-events", "delta-batch", "load-messages", "load-older", - "send-message", "reset-resync", "edit-message", "delete-message", + "send-message", "send-media-message", "reset-resync", "edit-message", "delete-message", ] // MARK: - Names From f581d7c4a95ebe825825d8436d58125a654c131d Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:17:44 -0400 Subject: [PATCH 03/19] fix(chat): layer the jump button under the transcript blur and over the attach shield --- .../FlipcashUI/Chat/ChatScreenViewController.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index dd6c730a7..a600dcbf8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -773,7 +773,8 @@ public final class ChatScreenViewController: UIViewController { shield.onTap = onTap shield.frame = transcript.view.frame shield.autoresizingMask = [.flexibleWidth, .flexibleHeight] - view.insertSubview(shield, aboveSubview: transcript.view) + // Above the jump button, so a tap while the attach keyboard is up dismisses it rather than jumping. + view.insertSubview(shield, aboveSubview: jumpButton) transcriptShield = shield } @@ -934,12 +935,13 @@ public final class ChatScreenViewController: UIViewController { private static let jumpHiddenScale: CGFloat = 0.8 /// Floats the jump button over the transcript, above the composer's clip rather than inside it - /// (the clip toggles `clipsToBounds`), with its haze behind. + /// (the clip toggles `clipsToBounds`), with its haze behind. Under the blur over a withheld + /// transcript, so the button doesn't float sharp over text the reader can't read. private func installJumpButton() { jumpHaze.translatesAutoresizingMaskIntoConstraints = false jumpButton.translatesAutoresizingMaskIntoConstraints = false - view.insertSubview(jumpHaze, belowSubview: barClip) - view.insertSubview(jumpButton, belowSubview: barClip) + view.insertSubview(jumpHaze, belowSubview: transcriptBlur.view) + view.insertSubview(jumpButton, belowSubview: transcriptBlur.view) NSLayoutConstraint.activate([ jumpHaze.leadingAnchor.constraint(equalTo: view.leadingAnchor), jumpHaze.trailingAnchor.constraint(equalTo: view.trailingAnchor), From f5517bf8103e98970b5a7d17483d02e8df4b2a21 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:17:44 -0400 Subject: [PATCH 04/19] fix(chat): drop the landed-open wasAtBottom recompute, which the divider open already sets --- FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index f847fb2c5..481bcd9f4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -1110,8 +1110,6 @@ public final class ChatViewController: UICollectionViewController { DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in guard let self, !hasPositioned else { return } - // An open placed at the unread divider is off the bottom, and no drag has said so. - wasAtBottom = isNearBottomByGeometry hasPositioned = true scheduleReadReport() } From 1305adbb3b46842529c07c72eec5df91dd0ca260 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:17:48 -0400 Subject: [PATCH 05/19] fix(chat): extend the jump button hit area over the badge overhang and keep an 18pt blue fill --- .../Sources/FlipcashUI/Chat/JumpToBottomButton.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift index 1a5cc4dc7..eb348e001 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift @@ -127,8 +127,13 @@ final class JumpToBottomButton: UIControl { accessibilityValue = text } - /// The pill: blue fill, white semibold 11, and a 2pt ring in the chat background that separates - /// it from the circle. + /// The badge overhangs the circle's corner; a tap on the overhang is a tap on the button. + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + super.point(inside: point, with: event) || (!badge.isHidden && badge.frame.contains(point)) + } + + /// The pill: blue fill (18pt tall inside the ring), white semibold 11, and a 2pt ring in the chat + /// background that separates it from the circle. private final class BadgeView: UIView { private let label = UILabel() @@ -152,7 +157,7 @@ final class JumpToBottomButton: UIControl { label.centerXAnchor.constraint(equalTo: centerXAnchor), label.centerYAnchor.constraint(equalTo: centerYAnchor), widthAnchor.constraint(greaterThanOrEqualTo: heightAnchor), - heightAnchor.constraint(equalToConstant: 20), + heightAnchor.constraint(equalToConstant: 22), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 5), label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -5), ]) From 76c32f2159507794cdc370bcc5b64e70e6baf4a4 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:18:03 -0400 Subject: [PATCH 06/19] feat(chat): advance the viewer read pointer to a confirmed own send, as Android does --- Flipcash/Core/Controllers/ConversationController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b71ed5eb1..6f97667dd 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -2189,6 +2189,9 @@ final class ConversationController { scheduleGapCatchUp(conversationID) } store.advanceLastActivity(to: message.date, in: conversationID) + // The server confirmed the send, so everything up to it is seen; matches Android's + // `MessagingDelegate` advancing its own pointer to the sent id. + advanceReadPointer(to: message.id, in: conversationID) refreshFeedPreview(for: conversationID) persistConversation(conversationID) // Outside the `ok` branch: the message reached the server either way, so a draft put @@ -2518,6 +2521,7 @@ final class ConversationController { scheduleGapCatchUp(conversationID) } store.advanceLastActivity(to: message.date, in: conversationID) + advanceReadPointer(to: message.id, in: conversationID) refreshFeedPreview(for: conversationID) persistConversation(conversationID) Analytics.sentMessage(chatType: chatType) From a088900ab81a6505a7105924a7755881d9278551 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:23:31 -0400 Subject: [PATCH 07/19] feat(chat): freeze the jump badge at the unread divider count until the first user drag --- .../Controllers/ConversationController.swift | 5 + .../ChatScreenRepresentable.swift | 4 + .../ConversationLoadCoordinator.swift | 3 + .../Conversation/ConversationScreen.swift | 3 +- .../Screens/Conversation/JumpBadgeCount.swift | 52 ++++++++ .../Database+Conversations.swift | 12 ++ FlipcashTests/Chat/JumpBadgeCountTests.swift | 114 ++++++++++++++++++ .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 7 ++ 9 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift create mode 100644 FlipcashTests/Chat/JumpBadgeCountTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 6f97667dd..40b3fee46 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1940,6 +1940,11 @@ final class ConversationController { (try? database.inboundMessageCount(conversationID: conversationID, after: messageID, excludingSender: selfUserID)) ?? 0 } + /// The newest message someone else sent, or nil when there is none. + func newestInboundMessageID(in conversationID: ConversationID) -> MessageID? { + ((try? database.newestInboundMessageID(conversationID: conversationID, excludingSender: selfUserID)) ?? nil) + } + /// Inbound messages the viewer hasn't seen: those after ``readThrough(for:)``, or 0 when the /// pointer is unknown (as ``unreadBoundary(for:)`` treats it). Observes the read pointer and /// `messageRevision`, so it re-evaluates when either the pointer moves or a message lands. diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index cafbb7081..7649dea34 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -60,6 +60,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let reportsReads: Bool /// Inbound messages after the viewer's read pointer, for the jump-to-bottom badge. let unreadBelowCount: Int + /// Fired on the reader's first drag after the opening scroll. + let onUserScrollBegan: () -> Void /// Fired when a reaction pill is tapped: the row's stable id and the toggled emoji. let onReactionTap: (String, String) -> Void /// Fired when a reaction pill is long-pressed: the row's stable id and emoji, to open the @@ -192,6 +194,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen screen.unreadBelowCount = unreadBelowCount + screen.onUserScrollBegan = onUserScrollBegan screen.reportsReads = reportsReads screen.update(items: items) context.coordinator.barHost = barHost @@ -243,6 +246,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onMentionRoomChange = { [mentions] room in mentions?.room = room } screen.onMessagesSeen = onMessagesSeen screen.unreadBelowCount = unreadBelowCount + screen.onUserScrollBegan = onUserScrollBegan screen.reportsReads = reportsReads // The backdrop is raised from the menu action itself (see `keyboardFollowing`) because it // has to claim the menu's blur before the dismissal fades it; it comes down here, whichever diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index d975fcf6e..2894048be 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -50,6 +50,8 @@ final class ConversationLoadCoordinator { /// `init`, which the screen runs as it appears and so before its opening task marks the chat /// read; never re-read for the life of this coordinator. private let openingUnreadBoundary: UnreadBoundary + /// The jump-to-bottom badge's count, frozen at the divider's until the reader first scrolls. + let jumpBadge: JumpBadgeCount /// The newest stored message at open. A message of the viewer's past it is a send made on this /// visit, which ends the divider under ``UnreadDividerLifetime/untilSend``. private let newestAtOpen: MessageID? @@ -95,6 +97,7 @@ final class ConversationLoadCoordinator { self.loader = MessageLoader(conversationID: conversationID, controller: controller) let boundary = controller.unreadBoundary(for: conversationID) self.openingUnreadBoundary = boundary + self.jumpBadge = JumpBadgeCount(controller: controller, conversationID: conversationID, boundary: boundary) self.newestAtOpen = controller.lastConfirmedMessage(for: conversationID)?.id self.claimReplies = CashLinkClaimReplies(claims: session.cashLinkClaims) { [controller] messageID in guard CashLinkClaimReplies.isEnabled else { return } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 353df5c00..38d914932 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -407,7 +407,8 @@ struct ConversationScreen: View { onQuoteTap: jumpToQuote, onMessagesSeen: markSeen, reportsReads: reportsReads(gate: gate), - unreadBelowCount: conversationID.map { conversationController.unreadBelowCount(in: $0) } ?? 0, + unreadBelowCount: coordinator?.jumpBadge.count ?? 0, + onUserScrollBegan: { coordinator?.jumpBadge.userDidScroll() }, onReactionTap: toggleReaction, onReactionLongPress: openReactors, onReactionAdd: openReactionPicker, diff --git a/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift new file mode 100644 index 000000000..78752647b --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift @@ -0,0 +1,52 @@ +// +// JumpBadgeCount.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Observation +import FlipcashCore + +/// The count on the jump-to-bottom badge for one visit to a chat. +/// +/// It opens equal to the unread divider's count and holds there until the first user drag, however +/// many of those rows are already on screen. A message that arrives while it holds adds to it. After +/// the drag it is the live count: inbound messages past the viewer's read pointer. +@MainActor +@Observable +final class JumpBadgeCount { + + /// True until the reader's first drag or fling after the open. Programmatic scrolls (the opening + /// position, auto-scroll on a new message, the jump button) don't clear it. + private(set) var isFrozen = true + + @ObservationIgnored private let controller: ConversationController + @ObservationIgnored private let conversationID: ConversationID + /// The unread divider's count at open; 0 when the chat opened without one. + @ObservationIgnored private let openingCount: Int + /// The newest inbound id at open; inbound messages past it arrived on this visit. + @ObservationIgnored private let arrivalMark: MessageID + + /// Pass the boundary resolved at open, before the opening read report moves the pointer. + init(controller: ConversationController, conversationID: ConversationID, boundary: UnreadBoundary) { + self.controller = controller + self.conversationID = conversationID + self.openingCount = boundary.count ?? 0 + self.arrivalMark = controller.newestInboundMessageID(in: conversationID) ?? MessageID(value: 0) + } + + /// The number to show; the badge hides at zero. + var count: Int { + guard isFrozen else { return controller.unreadBelowCount(in: conversationID) } + _ = controller.messageRevision // observe: re-count when an inbound message lands + return openingCount + controller.inboundMessageCount(after: arrivalMark, in: conversationID) + } + + /// Ends the freeze; the badge follows the live count from here on. + func userDidScroll() { + guard isFrozen else { return } + isFrozen = false + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 595f9bfcb..3a78773d5 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -272,6 +272,18 @@ nonisolated extension Database { return rows.compactMap { $0[m.senderId] } } + /// The newest stored message id someone other than `selfUserID` sent, deleted or not, or nil when + /// there is none. A row with no sender counts as someone else's. + public func newestInboundMessageID(conversationID: ConversationID, excludingSender selfUserID: UUID) throws -> MessageID? { + let m = ConversationMessageTable() + return try reader.pluck( + m.table.filter( + m.conversationId == conversationID.data + && (m.senderId == nil || m.senderId != selfUserID) + ).order(m.id.desc) + ).map { MessageID(value: $0[m.id]) } + } + /// The oldest stored message id after `after` that someone other than `selfUserID` sent, deleted /// or not, or nil when there is none — where the unread divider goes. A row with no sender counts /// as someone else's, except a system message, so the divider never lands above one. diff --git a/FlipcashTests/Chat/JumpBadgeCountTests.swift b/FlipcashTests/Chat/JumpBadgeCountTests.swift new file mode 100644 index 000000000..b3c2d587a --- /dev/null +++ b/FlipcashTests/Chat/JumpBadgeCountTests.swift @@ -0,0 +1,114 @@ +// +// JumpBadgeCountTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@MainActor +@Suite("Jump badge count") +struct JumpBadgeCountTests { + + private let id = ConversationID.test(1) + + private func message(_ id: UInt64, fromSelf: Bool) -> ConversationMessage { + UnreadFixtures.message(id, fromSelf: fromSelf) + } + + private func badge(for controller: ConversationController) -> JumpBadgeCount { + JumpBadgeCount(controller: controller, conversationID: id, boundary: controller.unreadBoundary(for: id)) + } + + /// Three unread inbound messages (2...4) behind a pointer at 1. + private func openedWithDivider(database: Database) async throws -> ConversationController { + try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: 1, database: database + ) + } + + @Test("opens at the divider's count even when some unread rows are already on screen") + func opening_equalsDividerCount() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await openedWithDivider(database: database) + let badge = badge(for: controller) + #expect(badge.count == 3) + + // The opening report marks the rows on screen read; the display holds. + controller.advanceReadPointer(to: MessageID(value: 3), in: id) + + #expect(controller.unreadBelowCount(in: id) == 1) + #expect(badge.count == 3) + } + + @Test("after the first user scroll the count follows the live rule") + func afterUserScroll_followsLiveCount() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await openedWithDivider(database: database) + let badge = badge(for: controller) + controller.advanceReadPointer(to: MessageID(value: 3), in: id) + #expect(badge.count == 3) + + badge.userDidScroll() + + #expect(badge.count == 1) + controller.advanceReadPointer(to: MessageID(value: 4), in: id) + #expect(badge.count == 0) + } + + @Test("a chat with no divider opens at zero") + func noDivider_isZero() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: (1...3).map { message($0, fromSelf: false) }, + pointer: 3, database: database + ) + + #expect(badge(for: controller).count == 0) + } + + @Test("an inbound arrival while frozen adds one to the divider's count") + func arrivalWhileFrozen_addsToDividerCount() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await openedWithDivider(database: database) + let badge = badge(for: controller) + let fired = Fired() + let before = withObservationTracking { + badge.count + } onChange: { + fired.set() + } + #expect(before == 3) + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(5, fromSelf: false)], conversationID: id) + } + + #expect(fired.value) + #expect(badge.count == 4) + } + + @Test("the user's own message arriving while frozen does not count") + func ownArrivalWhileFrozen_doesNotCount() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await openedWithDivider(database: database) + let badge = badge(for: controller) + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(5, fromSelf: true)], conversationID: id) + } + + #expect(badge.count == 3) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index a600dcbf8..bb5bf061d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -296,6 +296,12 @@ public final class ChatScreenViewController: UIViewController { } } + /// Fires on the reader's first drag after the opening scroll — see + /// ``ChatViewController/onUserScrollBegan``. + public var onUserScrollBegan: (() -> Void)? { + get { transcript.onUserScrollBegan } + set { transcript.onUserScrollBegan = newValue } + } /// Whether rows on screen count as read — see ``ChatViewController/reportsReads``. public var reportsReads: Bool { get { transcript.reportsReads } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 481bcd9f4..01cf4dca4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -217,6 +217,9 @@ public final class ChatViewController: UICollectionViewController { /// Fires when the jump-to-bottom button should appear or go: the opening scroll has landed and the /// reader is outside ``bottomThreshold`` of the newest message. var onJumpButtonVisibilityChange: ((Bool) -> Void)? + /// Fires when the reader starts a drag after the opening scroll has landed — the signal that + /// ends the jump badge's frozen count. Programmatic scrolls never fire it. + public var onUserScrollBegan: (() -> Void)? private var isJumpButtonVisible = false /// What this visit has reported read so far. private var readProgress = ReadProgress() @@ -1077,6 +1080,10 @@ public final class ChatViewController: UICollectionViewController { scrollToBottom(animated: items.count - 1 - lastVisible <= Self.jumpAnimationRowLimit) } + public override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + if hasPositioned { onUserScrollBegan?() } + } + public override func scrollViewDidScroll(_ scrollView: UIScrollView) { onScroll?() scheduleReadReport() From 3bdbf2d3eeb0642185918a2466905c5d9fb35129 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:23:31 -0400 Subject: [PATCH 08/19] test(chat): make the unread-count tests observe messageRevision, cover an unsynced-only pointer and own send --- .../Chat/UnreadBelowCountTests.swift | 58 ++++++++++++++++++- .../TestSupport/UnreadFixtures.swift | 17 ++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/FlipcashTests/Chat/UnreadBelowCountTests.swift b/FlipcashTests/Chat/UnreadBelowCountTests.swift index 449e30929..5263cc789 100644 --- a/FlipcashTests/Chat/UnreadBelowCountTests.swift +++ b/FlipcashTests/Chat/UnreadBelowCountTests.swift @@ -42,13 +42,14 @@ struct UnreadBelowCountTests { messages: (1...4).map { message($0, fromSelf: false) }, pointer: 1, database: database ) + #expect(controller.unreadBelowCount(in: id) == 3) controller.advanceReadPointer(to: MessageID(value: 2), in: id) #expect(controller.unreadBelowCount(in: id) == 2) } - @Test("an inbound message with a higher id increments the count") + @Test("an inbound message that lands through persist re-evaluates the count and increments it") func inboundArrival_increments() async throws { let (database, url) = try Database.makeTemp() defer { Database.removeTemp(at: url) } @@ -56,13 +57,56 @@ struct UnreadBelowCountTests { messages: [message(1, fromSelf: false), message(2, fromSelf: false)], pointer: 1, database: database ) - #expect(controller.unreadBelowCount(in: id) == 1) + let fired = Fired() + let before = withObservationTracking { + controller.unreadBelowCount(in: id) + } onChange: { + fired.set() + } + #expect(before == 1) + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(3, fromSelf: false)], conversationID: id) + } + + #expect(fired.value) + #expect(controller.unreadBelowCount(in: id) == 2) + } - try database.upsertConversationMessages([message(3, fromSelf: false)], conversationID: id) + @Test("a pointer that exists only as an unsynced advance still drives the count") + func unsyncedPointer_drivesCount() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: nil, database: database, hasConversationRow: false + ) + #expect(controller.unreadBelowCount(in: id) == 0) // no pointer yet: nothing counts + + controller.advanceReadPointer(to: MessageID(value: 2), in: id) + // The stored pointer is still absent; only the unsynced one reads through 2. + #expect(controller.store.selfReadPointer(for: id, selfUserID: UnreadFixtures.me) == nil) #expect(controller.unreadBelowCount(in: id) == 2) } + @Test("after the viewer's own send is confirmed the count is zero") + func ownSend_clearsCount() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.sendResult = UnreadFixtures.message(7, fromSelf: true) + let controller = try await UnreadFixtures.hydratedController( + messages: (1...3).map { message($0, fromSelf: false) }, + pointer: 1, database: database, mock: mock + ) + #expect(controller.unreadBelowCount(in: id) == 2) + + #expect(await controller.send("hi", to: id)) + + #expect(controller.unreadBelowCount(in: id) == 0) + } + @Test("an own message does not count, and seeing the newest inbound clears the count") func ownSend_doesNotCount_andSeeingNewestInboundClears() async throws { let (database, url) = try Database.makeTemp() @@ -93,3 +137,11 @@ struct UnreadBelowCountTests { #expect(controller.unreadBelowCount(in: id) == 0) } } + +/// Records that an observation-tracking `onChange` ran. +final class Fired: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + var value: Bool { lock.withLock { flag } } + func set() { lock.withLock { flag = true } } +} diff --git a/FlipcashTests/TestSupport/UnreadFixtures.swift b/FlipcashTests/TestSupport/UnreadFixtures.swift index 1dd4c5693..fe2082452 100644 --- a/FlipcashTests/TestSupport/UnreadFixtures.swift +++ b/FlipcashTests/TestSupport/UnreadFixtures.swift @@ -33,21 +33,26 @@ enum UnreadFixtures { } /// A controller hydrated from a database holding `messages` and, when `pointer` is non-nil, the - /// viewer's own member row reading through it. + /// viewer's own member row reading through it. `hasConversationRow: false` stores the messages + /// with no conversation, so a pointer advance lands only in the unsynced entry. `mock` is what + /// the controller sends through. static func hydratedController( messages: [ConversationMessage], pointer: UInt64?, - database: Database + database: Database, + hasConversationRow: Bool = true, + mock: MockConversations = MockConversations() ) async throws -> ConversationController { var members = [ConversationMember(userID: them, displayName: "Them")] if let pointer { members.append(ConversationMember(userID: me, displayName: "", readPointer: MessageID(value: pointer))) } - try database.upsertConversation( - Conversation(id: .test(1), members: members, lastMessage: nil, lastActivity: base) - ) + if hasConversationRow { + try database.upsertConversation( + Conversation(id: .test(1), members: members, lastMessage: nil, lastActivity: base) + ) + } try database.upsertConversationMessages(messages, conversationID: .test(1)) - let mock = MockConversations() let controller = ConversationController( fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), From baadeca6bc0447b211b2cb2dd1a8838ab4458c3c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:17:12 -0400 Subject: [PATCH 09/19] fix(chat): re-check at-bottom when a scroll ends without a drag A status-bar tap, a deceleration's end or a settled scroll animation never passed the drag check in scrollViewDidScroll, so the jump button kept its old visibility. --- .../FlipcashUI/Chat/ChatViewController.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 01cf4dca4..b32599472 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -1084,6 +1084,24 @@ public final class ChatViewController: UICollectionViewController { if hasPositioned { onUserScrollBegan?() } } + // A scroll that no drag drives (status-bar tap, deceleration's end, a programmatic animation) never + // passes the drag check in `scrollViewDidScroll`, so each path that ends one re-reads the offset. + public override func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + wasAtBottom = isNearBottomByGeometry + } + + public override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + wasAtBottom = isNearBottomByGeometry + } + + public override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { wasAtBottom = isNearBottomByGeometry } + } + + public override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + wasAtBottom = isNearBottomByGeometry + } + public override func scrollViewDidScroll(_ scrollView: UIScrollView) { onScroll?() scheduleReadReport() From 82284c10fabeb44736ff5cf988a9e7b894859835 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:17:13 -0400 Subject: [PATCH 10/19] fix(chat): count only post-load arrivals on the jump badge and release the hold on own send The arrival cutoff is now the larger of the read pointer at open and the newest inbound id once the opening load has landed, so loaded history no longer counts as arrivals. A confirmed own send, text or media, ends the frozen count. --- .../Controllers/ConversationController.swift | 8 +- .../Conversation/ConversationScreen.swift | 3 + .../Screens/Conversation/JumpBadgeCount.swift | 43 +++++++--- FlipcashTests/Chat/JumpBadgeCountTests.swift | 80 +++++++++++++++++++ 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 40b3fee46..56fac11af 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1797,6 +1797,10 @@ final class ConversationController { /// re-fire through the store directly. private(set) var messageRevision = 0 + /// Own sends the server has confirmed this session, per chat. Observed by the jump badge, which + /// reads a rise as the viewer having caught up with the transcript. + private(set) var confirmedOwnSendCounts: [ConversationID: Int] = [:] + /// Forces the transcript to re-read its window. `persist(operation:)` does this for database /// writes; an overlay change writes nothing, so it has to say so explicitly. func bumpMessageRevision() { @@ -1928,7 +1932,7 @@ final class ConversationController { /// The newest inbound id the viewer has seen: the stored READ pointer or the unsynced one ahead of /// it. The one value the unread divider, the jump-to-bottom badge, and /// ``advanceReadPointer(to:in:)`` all read, so they can't disagree. - private func readThrough(for conversationID: ConversationID) -> MessageID? { + func readThrough(for conversationID: ConversationID) -> MessageID? { [ store.selfReadPointer(for: conversationID, selfUserID: selfUserID), store.unsyncedSelfReadPointers[conversationID], @@ -2197,6 +2201,7 @@ final class ConversationController { // The server confirmed the send, so everything up to it is seen; matches Android's // `MessagingDelegate` advancing its own pointer to the sent id. advanceReadPointer(to: message.id, in: conversationID) + confirmedOwnSendCounts[conversationID, default: 0] += 1 refreshFeedPreview(for: conversationID) persistConversation(conversationID) // Outside the `ok` branch: the message reached the server either way, so a draft put @@ -2527,6 +2532,7 @@ final class ConversationController { } store.advanceLastActivity(to: message.date, in: conversationID) advanceReadPointer(to: message.id, in: conversationID) + confirmedOwnSendCounts[conversationID, default: 0] += 1 refreshFeedPreview(for: conversationID) persistConversation(conversationID) Analytics.sentMessage(chatType: chatType) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 38d914932..c25bf3423 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1252,6 +1252,9 @@ struct ConversationScreen: View { // chat needs no separate catch-up. Missed-while-open windows are reconciled by the // foreground / reconnect / live-gap triggers instead. await conversationController.loadMessages(for: conversationID) + if let coordinator, coordinator.conversationID == conversationID { + coordinator.jumpBadge.openingLoadLanded() + } await conversationController.refreshReactions(for: conversationID) // Reading the thread clears its own delivered pushes; other chats keep theirs. await pushController.clearDeliveredNotifications(for: conversationID) diff --git a/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift index 78752647b..1b3f09365 100644 --- a/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift +++ b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift @@ -12,41 +12,60 @@ import FlipcashCore /// The count on the jump-to-bottom badge for one visit to a chat. /// /// It opens equal to the unread divider's count and holds there until the first user drag, however -/// many of those rows are already on screen. A message that arrives while it holds adds to it. After -/// the drag it is the live count: inbound messages past the viewer's read pointer. +/// many of those rows are already on screen. An inbound message that arrives after the opening load +/// has landed adds to it. After the drag it is the live count: inbound messages past the viewer's +/// read pointer. A confirmed own send also ends the hold, since the viewer has caught up. @MainActor @Observable final class JumpBadgeCount { - /// True until the reader's first drag or fling after the open. Programmatic scrolls (the opening - /// position, auto-scroll on a new message, the jump button) don't clear it. - private(set) var isFrozen = true - @ObservationIgnored private let controller: ConversationController @ObservationIgnored private let conversationID: ConversationID /// The unread divider's count at open; 0 when the chat opened without one. @ObservationIgnored private let openingCount: Int - /// The newest inbound id at open; inbound messages past it arrived on this visit. - @ObservationIgnored private let arrivalMark: MessageID + /// The viewer's read pointer at open, before the opening read report moves it. + @ObservationIgnored private let pointerAtOpen: MessageID? + /// Own sends already confirmed at open; a rise past it releases the hold. + @ObservationIgnored private let sendsAtOpen: Int + /// Inbound messages past this id arrived on this visit. Nil until the opening load has landed. + @ObservationIgnored private var arrivalCutoff: MessageID? + private var didScroll = false /// Pass the boundary resolved at open, before the opening read report moves the pointer. init(controller: ConversationController, conversationID: ConversationID, boundary: UnreadBoundary) { self.controller = controller self.conversationID = conversationID self.openingCount = boundary.count ?? 0 - self.arrivalMark = controller.newestInboundMessageID(in: conversationID) ?? MessageID(value: 0) + self.pointerAtOpen = controller.readThrough(for: conversationID) + self.sendsAtOpen = controller.confirmedOwnSendCounts[conversationID] ?? 0 + } + + /// True until the reader's first drag or fling after the open, or the viewer's own send is + /// confirmed. Programmatic scrolls (the opening position, auto-scroll on a new message, the jump + /// button) don't clear it. + var isFrozen: Bool { + !didScroll && (controller.confirmedOwnSendCounts[conversationID] ?? 0) == sendsAtOpen } /// The number to show; the badge hides at zero. var count: Int { guard isFrozen else { return controller.unreadBelowCount(in: conversationID) } _ = controller.messageRevision // observe: re-count when an inbound message lands - return openingCount + controller.inboundMessageCount(after: arrivalMark, in: conversationID) + guard let arrivalCutoff else { return openingCount } + return openingCount + controller.inboundMessageCount(after: arrivalCutoff, in: conversationID) + } + + /// Marks the opening page load as landed, so the history it brought in doesn't count as arrivals. + /// Later calls do nothing. + func openingLoadLanded() { + guard arrivalCutoff == nil else { return } + let newest = controller.newestInboundMessageID(in: conversationID) + arrivalCutoff = [pointerAtOpen, newest].compactMap { $0 }.max() ?? MessageID(value: 0) } /// Ends the freeze; the badge follows the live count from here on. func userDidScroll() { - guard isFrozen else { return } - isFrozen = false + guard !didScroll else { return } + didScroll = true } } diff --git a/FlipcashTests/Chat/JumpBadgeCountTests.swift b/FlipcashTests/Chat/JumpBadgeCountTests.swift index b3c2d587a..bc545a437 100644 --- a/FlipcashTests/Chat/JumpBadgeCountTests.swift +++ b/FlipcashTests/Chat/JumpBadgeCountTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import UIKit import FlipcashCore import FlipcashStore @testable import Flipcash @@ -82,6 +83,7 @@ struct JumpBadgeCountTests { defer { Database.removeTemp(at: url) } let controller = try await openedWithDivider(database: database) let badge = badge(for: controller) + badge.openingLoadLanded() let fired = Fired() let before = withObservationTracking { badge.count @@ -104,6 +106,7 @@ struct JumpBadgeCountTests { defer { Database.removeTemp(at: url) } let controller = try await openedWithDivider(database: database) let badge = badge(for: controller) + badge.openingLoadLanded() controller.persist(operation: "upsert-messages") { try database.upsertConversationMessages([message(5, fromSelf: true)], conversationID: id) @@ -111,4 +114,81 @@ struct JumpBadgeCountTests { #expect(badge.count == 3) } + + @Test("history the opening load brings in is not an arrival") + func openingLoadHistory_isNotAnArrival() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + // An empty cache: nothing stored when the badge is built. + let controller = try await UnreadFixtures.hydratedController(messages: [], pointer: nil, database: database) + let badge = badge(for: controller) + + controller.persist(operation: "load-messages") { + try database.upsertConversationMessages((1...50).map { message($0, fromSelf: false) }, conversationID: id) + } + badge.openingLoadLanded() + + #expect(badge.count == 0) + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(51, fromSelf: false)], conversationID: id) + } + #expect(badge.count == 1) + } + + @Test("the arrival cutoff uses the larger of the stored read pointer and the unsynced one") + func arrivalCutoff_usesMaxPointer() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: 5, database: database + ) + // An unsynced advance behind the stored pointer: the stored 5 must win. + controller.store.advanceSelfReadPointer(to: MessageID(value: 3), in: id, selfUserID: UnreadFixtures.me) + let badge = badge(for: controller) + badge.openingLoadLanded() + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(5, fromSelf: false)], conversationID: id) + } + + // 5 is at the stored pointer, so it is not an arrival; a cutoff of 3 or 4 would count it. + #expect(badge.count == 0) + } + + @Test("a confirmed own text send zeroes a still-frozen badge") + func ownSend_zeroesFrozenBadge() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.sendResult = message(7, fromSelf: true) + let controller = try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: 1, database: database, mock: mock + ) + let badge = badge(for: controller) + badge.openingLoadLanded() + #expect(badge.count == 3) + + #expect(await controller.send("hi", to: id)) + + #expect(badge.count == 0) + } + + @Test("a confirmed media send zeroes a still-frozen badge") + func mediaSend_zeroesFrozenBadge() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await openedWithDivider(database: database) + let badge = badge(for: controller) + badge.openingLoadLanded() + #expect(badge.count == 3) + + let chip = ComposerChip(image: UIImage()) + chip.uploadTask = Task { .plain(BlobID(data: Data("photo".utf8))) } + #expect(await controller.sendMedia([chip], caption: nil, to: id)) + + #expect(badge.count == 0) + } } From 4c9180be35b7898a32bae47cd3c9f6398c010c14 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 08:28:31 -0400 Subject: [PATCH 11/19] fix(chat): apply the opening-load landing to a late coordinator and release the badge only onto a moved pointer A coordinator built after the opening load now starts with the load marked landed, so arrivals still count. An own send bumps the release counter only when advanceReadPointer reports the pointer at or past it. --- .../Controllers/ConversationController.swift | 22 +++++++--- .../ConversationLoadCoordinator.swift | 5 ++- .../Conversation/ConversationScreen.swift | 7 ++- .../Screens/Conversation/JumpBadgeCount.swift | 4 +- FlipcashTests/Chat/JumpBadgeCountTests.swift | 44 +++++++++++++++++++ .../TestSupport/UnreadFixtures.swift | 3 +- 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 56fac11af..e7a7f5ca9 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -2200,8 +2200,10 @@ final class ConversationController { store.advanceLastActivity(to: message.date, in: conversationID) // The server confirmed the send, so everything up to it is seen; matches Android's // `MessagingDelegate` advancing its own pointer to the sent id. - advanceReadPointer(to: message.id, in: conversationID) - confirmedOwnSendCounts[conversationID, default: 0] += 1 + // The jump badge's hold releases only onto a pointer that moved. + if advanceReadPointer(to: message.id, in: conversationID) { + confirmedOwnSendCounts[conversationID, default: 0] += 1 + } refreshFeedPreview(for: conversationID) persistConversation(conversationID) // Outside the `ok` branch: the message reached the server either way, so a draft put @@ -2531,8 +2533,10 @@ final class ConversationController { scheduleGapCatchUp(conversationID) } store.advanceLastActivity(to: message.date, in: conversationID) - advanceReadPointer(to: message.id, in: conversationID) - confirmedOwnSendCounts[conversationID, default: 0] += 1 + // The jump badge's hold releases only onto a pointer that moved. + if advanceReadPointer(to: message.id, in: conversationID) { + confirmedOwnSendCounts[conversationID, default: 0] += 1 + } refreshFeedPreview(for: conversationID) persistConversation(conversationID) Analytics.sentMessage(chatType: chatType) @@ -2557,13 +2561,16 @@ final class ConversationController { /// inbound messages it crossed report their receipts. `AdvancePointer` goes out after. When it /// fails, the local pointer stays ahead and the next feed load sends it again. Android's /// `MessagingDelegate.advanceReadPointer` works in the same order. - func advanceReadPointer(to messageID: MessageID, in conversationID: ConversationID) { + /// + /// Returns whether the pointer now sits at or past `messageID`; false when the viewer can't move it. + @discardableResult + func advanceReadPointer(to messageID: MessageID, in conversationID: ConversationID) -> Bool { // A pointer advance is denied to a non-member even when the fetch isn't, and a chat the user // cannot read has nothing to mark. - guard canAdvancePointer(conversationID) else { return } + guard canAdvancePointer(conversationID) else { return false } // A chat not in the feed yet has no member row to hold the pointer, only the unsynced entry. let previous = readThrough(for: conversationID) - if let previous, messageID <= previous { return } + if let previous, messageID <= previous { return true } store.advanceSelfReadPointer(to: messageID, in: conversationID, selfUserID: selfUserID) // The pointer only moves forward, so the window it just crossed holds exactly the messages the // reader is seeing for the first time. The reporter keeps only the inbound ones. @@ -2571,6 +2578,7 @@ final class ConversationController { receipts.reportRead(crossed, chatType: conversation(withID: conversationID)?.type) persistConversation(conversationID) syncReadPointer(in: conversationID) + return true } /// Sends the viewer's unsynced READ pointer for `conversationID`, if it has one. Called as the diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 2894048be..719d2af48 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -86,7 +86,8 @@ final class ConversationLoadCoordinator { session: Session, knownAuthors: KnownAuthorDirectory, prefetchWebCards: @escaping @MainActor ([LinkCard]) -> Void = { _ in }, - isAppActive: @escaping @MainActor () -> Bool = { UIApplication.shared.applicationState == .active } + isAppActive: @escaping @MainActor () -> Bool = { UIApplication.shared.applicationState == .active }, + openingLoadLanded: Bool = false ) { self.conversationID = conversationID self.controller = controller @@ -97,7 +98,7 @@ final class ConversationLoadCoordinator { self.loader = MessageLoader(conversationID: conversationID, controller: controller) let boundary = controller.unreadBoundary(for: conversationID) self.openingUnreadBoundary = boundary - self.jumpBadge = JumpBadgeCount(controller: controller, conversationID: conversationID, boundary: boundary) + self.jumpBadge = JumpBadgeCount(controller: controller, conversationID: conversationID, boundary: boundary, openingLoadLanded: openingLoadLanded) self.newestAtOpen = controller.lastConfirmedMessage(for: conversationID)?.id self.claimReplies = CashLinkClaimReplies(claims: session.cashLinkClaims) { [controller] messageID in guard CashLinkClaimReplies.isEnabled else { return } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index c25bf3423..61330caf5 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -53,6 +53,8 @@ struct ConversationScreen: View { @Environment(\.scenePhase) private var scenePhase @State private var didInitialRead = false + /// The chat whose opening load has landed, for a coordinator built after it. + @State private var loadedConversationID: ConversationID? /// Set once the saved link previews have loaded or 300 ms have passed, so a chat opened right /// after launch draws its cards from them instead of flashing placeholders (P22d). @State private var waitedForLinkPreviews = false @@ -1252,6 +1254,8 @@ struct ConversationScreen: View { // chat needs no separate catch-up. Missed-while-open windows are reconciled by the // foreground / reconnect / live-gap triggers instead. await conversationController.loadMessages(for: conversationID) + // The coordinator may not exist yet; `syncCoordinator` applies this when it builds one. + loadedConversationID = conversationID if let coordinator, coordinator.conversationID == conversationID { coordinator.jumpBadge.openingLoadLanded() } @@ -1434,7 +1438,8 @@ struct ConversationScreen: View { controller: conversationController, session: session, knownAuthors: sessionContainer.knownAuthors, - prefetchWebCards: { [linkCardFeed = sessionContainer.linkCardFeed] in linkCardFeed.prefetch($0) } + prefetchWebCards: { [linkCardFeed = sessionContainer.linkCardFeed] in linkCardFeed.prefetch($0) }, + openingLoadLanded: loadedConversationID == id ) } } diff --git a/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift index 1b3f09365..52811dfc5 100644 --- a/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift +++ b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift @@ -32,12 +32,14 @@ final class JumpBadgeCount { private var didScroll = false /// Pass the boundary resolved at open, before the opening read report moves the pointer. - init(controller: ConversationController, conversationID: ConversationID, boundary: UnreadBoundary) { + /// `openingLoadLanded` is true when the opening load finished before this was built. + init(controller: ConversationController, conversationID: ConversationID, boundary: UnreadBoundary, openingLoadLanded: Bool = false) { self.controller = controller self.conversationID = conversationID self.openingCount = boundary.count ?? 0 self.pointerAtOpen = controller.readThrough(for: conversationID) self.sendsAtOpen = controller.confirmedOwnSendCounts[conversationID] ?? 0 + if openingLoadLanded { self.openingLoadLanded() } } /// True until the reader's first drag or fling after the open, or the viewer's own send is diff --git a/FlipcashTests/Chat/JumpBadgeCountTests.swift b/FlipcashTests/Chat/JumpBadgeCountTests.swift index bc545a437..6bd947f88 100644 --- a/FlipcashTests/Chat/JumpBadgeCountTests.swift +++ b/FlipcashTests/Chat/JumpBadgeCountTests.swift @@ -191,4 +191,48 @@ struct JumpBadgeCountTests { #expect(badge.count == 0) } + + @Test("a badge built after the opening load landed still excludes the loaded history") + func builtAfterLoad_excludesHistory() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: (1...50).map { message($0, fromSelf: false) }, + pointer: 50, database: database + ) + let badge = JumpBadgeCount( + controller: controller, conversationID: id, + boundary: controller.unreadBoundary(for: id), openingLoadLanded: true + ) + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(51, fromSelf: false)], conversationID: id) + } + + #expect(badge.count == 1) + } + + @Test("an own send the viewer can't advance the pointer for leaves the badge held") + func ownSend_withoutPointerAdvance_keepsHold() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.sendResult = message(9, fromSelf: true) + // A group the viewer hasn't joined: the pointer can't move. + let controller = try await UnreadFixtures.hydratedController( + messages: (1...3).map { message($0, fromSelf: false) }, + pointer: nil, database: database, type: .group, mock: mock + ) + let badge = badge(for: controller) + badge.openingLoadLanded() + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(4, fromSelf: false)], conversationID: id) + } + #expect(badge.count == 1, "before send: \(badge.count)") + + #expect(await controller.send("hi", to: id)) + + #expect(badge.isFrozen) + #expect(controller.confirmedOwnSendCounts[id] == nil) + } } diff --git a/FlipcashTests/TestSupport/UnreadFixtures.swift b/FlipcashTests/TestSupport/UnreadFixtures.swift index fe2082452..a24aeac09 100644 --- a/FlipcashTests/TestSupport/UnreadFixtures.swift +++ b/FlipcashTests/TestSupport/UnreadFixtures.swift @@ -41,6 +41,7 @@ enum UnreadFixtures { pointer: UInt64?, database: Database, hasConversationRow: Bool = true, + type: ConversationType = .contactDm, mock: MockConversations = MockConversations() ) async throws -> ConversationController { var members = [ConversationMember(userID: them, displayName: "Them")] @@ -49,7 +50,7 @@ enum UnreadFixtures { } if hasConversationRow { try database.upsertConversation( - Conversation(id: .test(1), members: members, lastMessage: nil, lastActivity: base) + Conversation(id: .test(1), members: members, lastMessage: nil, lastActivity: base, type: type) ) } try database.upsertConversationMessages(messages, conversationID: .test(1)) From 5423be1191c69f3cda19507dd08dd215df291aad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 09:14:54 -0400 Subject: [PATCH 12/19] fix(chat): seat the jump badge's arrival cutoff only after a successful newest-page load loadMessages now reports whether the page was fetched and persisted. The screen seats the cutoff only on success, and the controller records each landing so a retry, backfill or join after a failed opening load seats it instead. Until then nothing counts as an arrival. --- .../Controllers/ConversationController.swift | 22 ++++++- .../Conversation/ConversationScreen.swift | 14 ++-- .../Screens/Conversation/JumpBadgeCount.swift | 14 ++++ FlipcashTests/Chat/JumpBadgeCountTests.swift | 65 +++++++++++++++++++ .../TestSupport/MockConversations.swift | 7 ++ .../TestSupport/UnreadFixtures.swift | 6 +- 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index e7a7f5ca9..b221ff49d 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1992,12 +1992,15 @@ final class ConversationController { return store.hasPendingMessages(for: conversationID) || (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 } != nil } - func loadMessages(for conversationID: ConversationID) async { + /// Loads the newest page. Returns whether it was fetched and persisted; a skipped, failed or + /// unpersisted load returns false and records no landing. + @discardableResult + func loadMessages(for conversationID: ConversationID) async -> Bool { guard canRead(conversationID) else { logger.info("Skipping message load for a conversation the user may not read", metadata: [ "conversationID": "\(conversationID)", ]) - return + return false } messageLoadsInFlight.insert(conversationID) // This load is the backfill: a queued one would fetch the same page again. @@ -2024,17 +2027,32 @@ final class ConversationController { if ok { commitReconciled(pairs, in: conversationID) if head > 0 { store.setAppliedCursor(head, for: conversationID) } + recordNewestPageLanding(in: conversationID) } refreshFeedPreview(for: conversationID) + return ok } catch { logger.error("Failed to load conversation messages", metadata: [ "conversationID": "\(conversationID)", "error": "\(error)", ]) ErrorReporting.captureError(error, reason: "Failed to load conversation messages") + return false } } + /// Successful newest-page loads per chat, from any path: the opening load, a retry, a backfill, + /// the gate lifting, a join. + @ObservationIgnored private(set) var newestPageLandings: [ConversationID: Int] = [:] + /// The newest inbound id stored when the latest landing finished, so the history that page + /// brought in can be told apart from what arrives after it. + @ObservationIgnored private(set) var newestInboundAtLanding: [ConversationID: MessageID] = [:] + + private func recordNewestPageLanding(in conversationID: ConversationID) { + newestPageLandings[conversationID, default: 0] += 1 + newestInboundAtLanding[conversationID] = newestInboundMessageID(in: conversationID) + } + // MARK: - Pagination /// Per-conversation older-history paging state, observed so the transcript can diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 61330caf5..7ae0ddf73 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1253,11 +1253,15 @@ struct ConversationScreen: View { // The newest page IS the fresh state and seats the event-log cursor to head, so opening a // chat needs no separate catch-up. Missed-while-open windows are reconciled by the // foreground / reconnect / live-gap triggers instead. - await conversationController.loadMessages(for: conversationID) - // The coordinator may not exist yet; `syncCoordinator` applies this when it builds one. - loadedConversationID = conversationID - if let coordinator, coordinator.conversationID == conversationID { - coordinator.jumpBadge.openingLoadLanded() + let landed = await conversationController.loadMessages(for: conversationID) + // Only a load that landed seats the badge's arrival cutoff; after a failed one, the next + // successful newest-page load does, whichever path runs it. + if landed { + // The coordinator may not exist yet; `syncCoordinator` applies this when it builds one. + loadedConversationID = conversationID + if let coordinator, coordinator.conversationID == conversationID { + coordinator.jumpBadge.openingLoadLanded() + } } await conversationController.refreshReactions(for: conversationID) // Reading the thread clears its own delivered pushes; other chats keep theirs. diff --git a/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift index 52811dfc5..50c0c8fd3 100644 --- a/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift +++ b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift @@ -29,6 +29,8 @@ final class JumpBadgeCount { @ObservationIgnored private let sendsAtOpen: Int /// Inbound messages past this id arrived on this visit. Nil until the opening load has landed. @ObservationIgnored private var arrivalCutoff: MessageID? + /// Newest-page loads this chat had landed when the badge was built; a rise is a landing on this visit. + @ObservationIgnored private let landingsAtOpen: Int private var didScroll = false /// Pass the boundary resolved at open, before the opening read report moves the pointer. @@ -39,6 +41,7 @@ final class JumpBadgeCount { self.openingCount = boundary.count ?? 0 self.pointerAtOpen = controller.readThrough(for: conversationID) self.sendsAtOpen = controller.confirmedOwnSendCounts[conversationID] ?? 0 + self.landingsAtOpen = controller.newestPageLandings[conversationID] ?? 0 if openingLoadLanded { self.openingLoadLanded() } } @@ -53,6 +56,7 @@ final class JumpBadgeCount { var count: Int { guard isFrozen else { return controller.unreadBelowCount(in: conversationID) } _ = controller.messageRevision // observe: re-count when an inbound message lands + seatCutoffFromLanding() guard let arrivalCutoff else { return openingCount } return openingCount + controller.inboundMessageCount(after: arrivalCutoff, in: conversationID) } @@ -65,6 +69,16 @@ final class JumpBadgeCount { arrivalCutoff = [pointerAtOpen, newest].compactMap { $0 }.max() ?? MessageID(value: 0) } + /// Seats the cutoff when a newest-page load landed on this visit by a path that didn't call + /// ``openingLoadLanded()`` (a backfill, or a retry after the opening load failed). Until one + /// has, nothing counts as an arrival. + private func seatCutoffFromLanding() { + guard arrivalCutoff == nil, + (controller.newestPageLandings[conversationID] ?? 0) > landingsAtOpen else { return } + let newest = controller.newestInboundAtLanding[conversationID] + arrivalCutoff = [pointerAtOpen, newest].compactMap { $0 }.max() ?? MessageID(value: 0) + } + /// Ends the freeze; the badge follows the live count from here on. func userDidScroll() { guard !didScroll else { return } diff --git a/FlipcashTests/Chat/JumpBadgeCountTests.swift b/FlipcashTests/Chat/JumpBadgeCountTests.swift index 6bd947f88..cf78bd805 100644 --- a/FlipcashTests/Chat/JumpBadgeCountTests.swift +++ b/FlipcashTests/Chat/JumpBadgeCountTests.swift @@ -235,4 +235,69 @@ struct JumpBadgeCountTests { #expect(badge.isFrozen) #expect(controller.confirmedOwnSendCounts[id] == nil) } + + @Test("a failed opening load seats no cutoff; the next successful newest-page load does") + func failedOpeningLoad_thenLaterLoad_isNotArrivals() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + mock.messagesError = URLError(.notConnectedToInternet) + let controller = try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: 1, database: database, mock: mock + ) + let badge = badge(for: controller) + + #expect(await controller.loadMessages(for: id) == false) + #expect(badge.count == 3) + + // A later load by another path (a backfill, say) stores 20 more inbound; it is history. + mock.messagesError = nil + mock.messages = (1...24).map { message($0, fromSelf: false) } + #expect(await controller.loadMessages(for: id)) + #expect(badge.count == 3) + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(25, fromSelf: false)], conversationID: id) + } + #expect(badge.count == 4) + } + + @Test("a confirmed own send reports the crossed inbound messages, as Android's advance does") + func ownSend_reportsCrossedMessages() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let spy = ReceiptSpy() + let mock = MockConversations() + mock.sendResult = message(7, fromSelf: true) + let controller = try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: 1, database: database, mock: mock, + receipts: spy.makeReporter(selfUserID: UnreadFixtures.me) + ) + + #expect(await controller.send("hi", to: id)) + + #expect(spy.messages.count == 3) + try await waitUntil { mock.markedRead.last == MessageID(value: 7) } + } + + @Test("a confirmed own send that doesn't move the pointer reports nothing") + func ownSend_pointerNotMoved_reportsNothing() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let spy = ReceiptSpy() + let mock = MockConversations() + mock.sendResult = message(7, fromSelf: true) + let controller = try await UnreadFixtures.hydratedController( + messages: (1...4).map { message($0, fromSelf: false) }, + pointer: 9, database: database, mock: mock, + receipts: spy.makeReporter(selfUserID: UnreadFixtures.me) + ) + + #expect(await controller.send("hi", to: id)) + + #expect(spy.messages.isEmpty) + #expect(spy.tips.isEmpty) + } } diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index 5d4980151..c95aa2e2f 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -383,6 +383,12 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con set { lock.withLock { _messagesDelay = newValue } } } private var _messagesDelay: Duration = .zero + /// When set, the newest-page `getMessages` throws this instead of returning `messages`. + var messagesError: Error? { + get { lock.withLock { _messagesError } } + set { lock.withLock { _messagesError = newValue } } + } + private var _messagesError: Error? func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationMessage? { lock.withLock { _singleMessageQueries.append(messageID) } @@ -395,6 +401,7 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con guard let before else { lock.withLock { _latestPageQueries.append(conversationID) } if messagesDelay > .zero { try? await Task.sleep(for: messagesDelay) } + if let error = messagesError { throw error } return messages } lock.withLock { _olderQueries.append(before) } diff --git a/FlipcashTests/TestSupport/UnreadFixtures.swift b/FlipcashTests/TestSupport/UnreadFixtures.swift index a24aeac09..d1d63d27e 100644 --- a/FlipcashTests/TestSupport/UnreadFixtures.swift +++ b/FlipcashTests/TestSupport/UnreadFixtures.swift @@ -42,7 +42,8 @@ enum UnreadFixtures { database: Database, hasConversationRow: Bool = true, type: ConversationType = .contactDm, - mock: MockConversations = MockConversations() + mock: MockConversations = MockConversations(), + receipts: ConversationReceiptReporter? = nil ) async throws -> ConversationController { var members = [ConversationMember(userID: them, displayName: "Them")] if let pointer { @@ -58,7 +59,8 @@ enum UnreadFixtures { fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, contactNaming: MockDMContactNaming(), database: database, - owner: .generate()!, selfUserID: me + owner: .generate()!, selfUserID: me, + receipts: receipts ) await controller.hydrateFromDatabase() return controller From 87f721101487a3174214804a660fae4399df675b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 11:23:58 -0400 Subject: [PATCH 13/19] fix(chat): drop the gradient behind the jump-to-bottom button The painted fade covered the newest message rows, and the button's glass already separates it. --- .../Chat/ChatScreenViewController.swift | 13 ++------- .../FlipcashUI/Chat/ComposerFade.swift | 2 +- .../FlipcashUI/Chat/JumpToBottomButton.swift | 27 ------------------- 3 files changed, 3 insertions(+), 39 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index bb5bf061d..2bb1ac263 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -110,7 +110,6 @@ public final class ChatScreenViewController: UIViewController { /// The blur over a transcript the user is not allowed to read. See `TranscriptBlur`. private let transcriptBlur = TranscriptBlur() private let jumpButton = JumpToBottomButton() - private let jumpHaze = JumpHazeView() /// The decorative shapes the blur softens when there is no transcript to soften. See /// `GatePreviewPlaceholder`. private let gatePlaceholder = GatePreviewPlaceholder() @@ -936,23 +935,16 @@ public final class ChatScreenViewController: UIViewController { /// Gap between the jump button and the composer's top edge. private static let jumpButtonGap: CGFloat = 20 private static let jumpButtonInset: CGFloat = 16 - private static let jumpHazeHeight: CGFloat = 120 /// Scale the button sits at while hidden. private static let jumpHiddenScale: CGFloat = 0.8 /// Floats the jump button over the transcript, above the composer's clip rather than inside it - /// (the clip toggles `clipsToBounds`), with its haze behind. Under the blur over a withheld + /// (the clip toggles `clipsToBounds`), under the blur over a withheld /// transcript, so the button doesn't float sharp over text the reader can't read. private func installJumpButton() { - jumpHaze.translatesAutoresizingMaskIntoConstraints = false jumpButton.translatesAutoresizingMaskIntoConstraints = false - view.insertSubview(jumpHaze, belowSubview: transcriptBlur.view) view.insertSubview(jumpButton, belowSubview: transcriptBlur.view) NSLayoutConstraint.activate([ - jumpHaze.leadingAnchor.constraint(equalTo: view.leadingAnchor), - jumpHaze.trailingAnchor.constraint(equalTo: view.trailingAnchor), - jumpHaze.bottomAnchor.constraint(equalTo: barClip.topAnchor), - jumpHaze.heightAnchor.constraint(equalToConstant: Self.jumpHazeHeight), jumpButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.jumpButtonInset), jumpButton.bottomAnchor.constraint(equalTo: barClip.topAnchor, constant: -Self.jumpButtonGap), ]) @@ -963,13 +955,12 @@ public final class ChatScreenViewController: UIViewController { } } - /// Fades and scales the button and haze in or out. Hidden, the button also stops taking + /// Fades and scales the button in or out. Hidden, the button also stops taking /// touches; alpha 0 alone would still be tappable. private func setJumpButton(visible: Bool, animated: Bool) { let scale = visible ? 1 : Self.jumpHiddenScale let apply = { self.jumpButton.alpha = visible ? 1 : 0 - self.jumpHaze.alpha = visible ? 1 : 0 self.jumpButton.transform = CGAffineTransform(scaleX: scale, y: scale) } jumpButton.isUserInteractionEnabled = visible diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift index 6b9f7d373..22dc37a72 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ComposerFade.swift @@ -65,7 +65,7 @@ final class ComposerFadeView: UIView { } /// A view backed by a top-to-bottom gradient layer. -final class GradientView: UIView { +private final class GradientView: UIView { override class var layerClass: AnyClass { CAGradientLayer.self } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift index eb348e001..97cdd74c8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift @@ -10,33 +10,6 @@ import SwiftUI import UIKit import FlipcashCore -/// The gradient behind the jump button: the chat background rising from nothing to nearly opaque -/// toward the composer, so the button reads over any bubble beneath it. -@MainActor -final class JumpHazeView: UIView { - - private let ramp = GradientView() - - init() { - super.init(frame: .zero) - isUserInteractionEnabled = false - addSubview(ramp) - let base = UIColor(Color.backgroundMain) - ramp.set( - colors: [0, 0.75, 0.95].map { base.withAlphaComponent($0) }, - locations: [0, 0.5, 1] - ) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layoutSubviews() { - super.layoutSubviews() - ramp.frame = bounds - } -} - /// A 44pt glass circle with a chevron and an unread badge that scrolls the transcript to its newest /// message. White 12% over the chat's frosting, a hairline in the row-separator white, and a soft /// shadow. From 9735a3905a56e62408d2d9e44c1a78d4a196d67c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 11:28:51 -0400 Subject: [PATCH 14/19] fix(chat): line the jump-to-bottom button up with the $ button The jump button now centres on the Send Cash button's column instead of sitting at a fixed 16pt inset, so the two stack. The bar reports the column's centre from its own layout constants, for the keyboard-up and keyboard-down insets. --- .../ChatScreenRepresentable.swift | 2 ++ .../Conversation/ConversationBottomBar.swift | 6 +++++ .../Chat/ChatScreenViewController.swift | 25 +++++++++++++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 7649dea34..5c8c8b858 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -151,6 +151,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder screen.barRestingDrop = BarMetrics.compactDrop + screen.barTrailingControlCentreInsets = BarMetrics.trailingControlCentreInsets screen.authorAvatars = authorAvatars screen.transcriptTopInset = transcriptTopInset screen.onReachTop = onReachTop @@ -210,6 +211,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder screen.barRestingDrop = BarMetrics.compactDrop + screen.barTrailingControlCentreInsets = BarMetrics.trailingControlCentreInsets screen.transcriptTopInset = transcriptTopInset screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 29ad0b28b..fbb40084b 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -200,6 +200,12 @@ enum BarMetrics { static let compactDrop: CGFloat = 8 /// The margin between the compact bar's controls and the screen's sides. static let compactInset: CGFloat = 32 + /// Distances from the screen's side to the centre of the bar's trailing control (Send Cash), with + /// the keyboard up and down. The jump-to-bottom button centres on this column. + static let trailingControlCentreInsets = ( + keyboardUp: edgeInset + contentHeight / 2, + resting: compactInset + contentHeight / 2 + ) } /// What stands outside the message field, beside it. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 2bb1ac263..234a79289 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -934,7 +934,25 @@ public final class ChatScreenViewController: UIViewController { /// Gap between the jump button and the composer's top edge. private static let jumpButtonGap: CGFloat = 20 - private static let jumpButtonInset: CGFloat = 16 + /// Where the jump button's centre sits from the screen's trailing edge when the owner has not + /// said where the bar's trailing control is. + private static let jumpButtonDefaultCentreInset: CGFloat = 16 + JumpToBottomButton.diameter / 2 + /// Distances from the screen's trailing edge to the centre of the bar's trailing control, with + /// the keyboard up and with it down. The jump button centres on that column so the two stack. + /// The bar is a hosted SwiftUI view, so the screen cannot constrain to the control's view and takes the + /// numbers from the bar's own layout constants instead. + public var barTrailingControlCentreInsets: (keyboardUp: CGFloat, resting: CGFloat)? { + didSet { refreshJumpButtonColumn() } + } + private var jumpButtonCentre: NSLayoutConstraint? + + private func refreshJumpButtonColumn() { + guard let jumpButtonCentre, let keyboardFloor else { return } + let inset = barTrailingControlCentreInsets.map { keyboardFloor.isKeyboardUp ? $0.keyboardUp : $0.resting } + ?? Self.jumpButtonDefaultCentreInset + jumpButtonCentre.constant = -inset + } + /// Scale the button sits at while hidden. private static let jumpHiddenScale: CGFloat = 0.8 @@ -944,8 +962,10 @@ public final class ChatScreenViewController: UIViewController { private func installJumpButton() { jumpButton.translatesAutoresizingMaskIntoConstraints = false view.insertSubview(jumpButton, belowSubview: transcriptBlur.view) + let centre = jumpButton.centerXAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.jumpButtonDefaultCentreInset) + jumpButtonCentre = centre NSLayoutConstraint.activate([ - jumpButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.jumpButtonInset), + centre, jumpButton.bottomAnchor.constraint(equalTo: barClip.topAnchor, constant: -Self.jumpButtonGap), ]) jumpButton.addAction(UIAction { [weak self] _ in self?.transcript.scrollToBottomFromJumpButton() }, for: .touchUpInside) @@ -984,6 +1004,7 @@ public final class ChatScreenViewController: UIViewController { // pass later — and a bar moved after the pass leaves the transcript's inset below reading // the bar's old frame, with nothing to lay it out again. keyboardFloor.refresh() + refreshJumpButtonColumn() } public override func viewDidLayoutSubviews() { From b42ddd73dd371b537452ddf87467796881116ad4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:01:51 -0400 Subject: [PATCH 15/19] fix(chat): move the jump-to-bottom button with the $ button The jump button's column now follows the bar's compact state (the composer's focus) instead of the keyboard's visibility, and moves on the same spring the bar widens on. --- .../ChatScreenRepresentable.swift | 1 + .../Conversation/ConversationBottomBar.swift | 23 +++++++++++--- .../Chat/ChatScreenKeyboardFloorTests.swift | 21 +++++++++++++ .../Sources/FlipcashUI/Chat/ChatMotion.swift | 3 ++ .../Chat/ChatScreenViewController.swift | 31 ++++++++++++++----- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 5c8c8b858..af0fd3e51 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -364,6 +364,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } } .modifier(BarOverflowReporting(model: barModel) { [weak coordinator] in coordinator?.screen }) + .modifier(BarCompactReporting(model: barModel) { [weak coordinator] in coordinator?.screen }) ) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index fbb40084b..11c137a34 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -165,6 +165,19 @@ struct BarOverflowReporting: ViewModifier { } } +/// Tells the screen when the bar switches between its compact and composing layouts, so the +/// jump-to-bottom button moves with the `$` button. +struct BarCompactReporting: ViewModifier { + let model: ConversationBarModel + let screen: () -> ChatScreenViewController? + + func body(content: Content) -> some View { + content.onChange(of: model.isComposing, initial: true) { _, isComposing in + screen()?.isBarCompact = !isComposing + } + } +} + /// Single spring driving the bar's morphs: the focus-driven resize and the send-arrow pop. private let barMorphSpring = ChatMotion.swap.animation @@ -200,11 +213,11 @@ enum BarMetrics { static let compactDrop: CGFloat = 8 /// The margin between the compact bar's controls and the screen's sides. static let compactInset: CGFloat = 32 - /// Distances from the screen's side to the centre of the bar's trailing control (Send Cash), with - /// the keyboard up and down. The jump-to-bottom button centres on this column. + /// Distances from the screen's side to the centre of the bar's trailing control (Send Cash), while + /// composing and while compact. The jump-to-bottom button centres on this column. static let trailingControlCentreInsets = ( - keyboardUp: edgeInset + contentHeight / 2, - resting: compactInset + contentHeight / 2 + composing: edgeInset + contentHeight / 2, + compact: compactInset + contentHeight / 2 ) } @@ -325,7 +338,7 @@ struct ConversationBottomBar: View { var quoteThumbnailLocation: (ChatQuote.Kind) async -> ChatMediaLocation? = { _ in nil } /// The curve the bar narrows and widens on as the keyboard goes and comes. - private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) + private static let widthSpring = ChatMotion.barInset.animation /// `$` splitting from the field's glass and joining back into it. Low bounce, so the overshoot /// does not carry the drop back into the field it just left. private static let cashSpring = Animation.spring(duration: 0.42, bounce: 0.12) diff --git a/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift b/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift index 251b10a0b..073e1c2aa 100644 --- a/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift +++ b/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift @@ -114,6 +114,27 @@ struct ChatScreenKeyboardFloorTests { #expect(slack() == settled) } + @Test("The jump button's column follows the bar's compact state, not the keyboard") + func jumpButtonColumn_followsBarState() { + let (screen, _, window) = makeScreen() + screen.barTrailingControlCentreInsets = (composing: 37, compact: 57) + window.layoutIfNeeded() + + // Keyboard up while the bar is still compact: the bar has not moved its `$`, so neither + // does the jump button. + postKeyboard(endFrame: raisedKeyboard, isLocal: true) + window.layoutIfNeeded() + #expect(screen.jumpButtonCentreInset == 57) + + UIView.performWithoutAnimation { screen.isBarCompact = false } + #expect(screen.jumpButtonCentreInset == 37) + + // Keyboard down while the bar is still composing: the column stays with the bar. + postKeyboard(endFrame: raisedKeyboard.offsetBy(dx: 0, dy: keyboardHeight), isLocal: true) + window.layoutIfNeeded() + #expect(screen.jumpButtonCentreInset == 37) + } + private func firstCollectionView(in view: UIView) -> UICollectionView? { if let collectionView = view as? UICollectionView { return collectionView } return view.subviews.lazy.compactMap { firstCollectionView(in: $0) }.first diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index b84749603..fd7b797f3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -137,6 +137,9 @@ public nonisolated enum ChatMotion { public static let read = ChatSpring(duration: 0.26, bounce: 0.26) /// The bottom bar swapping between its action and composer states. public static let swap = ChatSpring(duration: 0.27, bounce: 0.31) + /// The bar narrowing and widening as the composer gains and loses focus. The jump-to-bottom + /// button moves on the same curve, so it keeps its place over the `$` button. + public static let barInset = ChatSpring(duration: 0.22, bounce: 0.14) /// The send arrow scaling in and out of the composer. public static let sendButton = ChatSpring(duration: 0.17, bounce: 0.34) /// A bubble's corners flattening as a bubble run forms. Deliberately the slowest of the diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 234a79289..f2f93d3c4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -937,18 +937,35 @@ public final class ChatScreenViewController: UIViewController { /// Where the jump button's centre sits from the screen's trailing edge when the owner has not /// said where the bar's trailing control is. private static let jumpButtonDefaultCentreInset: CGFloat = 16 + JumpToBottomButton.diameter / 2 - /// Distances from the screen's trailing edge to the centre of the bar's trailing control, with - /// the keyboard up and with it down. The jump button centres on that column so the two stack. - /// The bar is a hosted SwiftUI view, so the screen cannot constrain to the control's view and takes the - /// numbers from the bar's own layout constants instead. - public var barTrailingControlCentreInsets: (keyboardUp: CGFloat, resting: CGFloat)? { + /// Distances from the screen's trailing edge to the centre of the bar's trailing control, while + /// the user is composing and while the bar rests compact. The jump button centres on that column + /// so the two stack. The bar is a hosted SwiftUI view, so the screen cannot constrain to the + /// control's view and takes the numbers from the bar's own layout constants instead. + public var barTrailingControlCentreInsets: (composing: CGFloat, compact: CGFloat)? { didSet { refreshJumpButtonColumn() } } + + /// Whether the bar rests compact: the state the bar's own layout switches on, which is the + /// composer's focus rather than the keyboard's visibility. The jump button follows it, on the + /// bar's own curve, so it stays over the `$` button. + public var isBarCompact = true { + didSet { + guard isBarCompact != oldValue else { return } + refreshJumpButtonColumn() + guard view.window != nil else { return } + ChatMotion.barInset.animate(options: [.beginFromCurrentState, .allowUserInteraction]) { + self.view.layoutIfNeeded() + } + } + } private var jumpButtonCentre: NSLayoutConstraint? + /// Distance from the trailing edge to the jump button's centre, as last set. + var jumpButtonCentreInset: CGFloat? { jumpButtonCentre.map { -$0.constant } } + private func refreshJumpButtonColumn() { - guard let jumpButtonCentre, let keyboardFloor else { return } - let inset = barTrailingControlCentreInsets.map { keyboardFloor.isKeyboardUp ? $0.keyboardUp : $0.resting } + guard let jumpButtonCentre else { return } + let inset = barTrailingControlCentreInsets.map { isBarCompact ? $0.compact : $0.composing } ?? Self.jumpButtonDefaultCentreInset jumpButtonCentre.constant = -inset } From 0d06003782e06726340477a81ff579a3f1a2a2dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:08:14 -0400 Subject: [PATCH 16/19] fix(chat): keep system messages out of the badge's arrival cutoff --- .../Database+Conversations.swift | 4 +++- .../Chat/UnreadBelowCountTests.swift | 24 +++++++++++++++++++ FlipcashTests/Chat/UnreadDividerTests.swift | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 3a78773d5..816bf31cf 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -273,12 +273,14 @@ nonisolated extension Database { } /// The newest stored message id someone other than `selfUserID` sent, deleted or not, or nil when - /// there is none. A row with no sender counts as someone else's. + /// there is none. A row with no sender counts as someone else's, except a system message, which never + /// moves the arrival cutoff. public func newestInboundMessageID(conversationID: ConversationID, excludingSender selfUserID: UUID) throws -> MessageID? { let m = ConversationMessageTable() return try reader.pluck( m.table.filter( m.conversationId == conversationID.data + && m.kind != 6 && (m.senderId == nil || m.senderId != selfUserID) ).order(m.id.desc) ).map { MessageID(value: $0[m.id]) } diff --git a/FlipcashTests/Chat/UnreadBelowCountTests.swift b/FlipcashTests/Chat/UnreadBelowCountTests.swift index 5263cc789..852eef26b 100644 --- a/FlipcashTests/Chat/UnreadBelowCountTests.swift +++ b/FlipcashTests/Chat/UnreadBelowCountTests.swift @@ -125,6 +125,30 @@ struct UnreadBelowCountTests { #expect(controller.unreadBelowCount(in: id) == 0) } + @Test("a system message neither adds to the badge count nor becomes the newest inbound id") + func systemMessage_doesNotChangeCountOrNewestInbound() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = try await UnreadFixtures.hydratedController( + messages: [message(1, fromSelf: false), message(2, fromSelf: false), message(3, fromSelf: false)], + pointer: 1, database: database + ) + #expect(controller.unreadBelowCount(in: id) == 2) + + let notice = ConversationMessage( + id: MessageID(value: 4), + senderID: nil, + content: .system(fallbackText: "Alex joined"), + date: UnreadFixtures.base.addingTimeInterval(240), + unreadSeq: 4 + ) + try database.upsertConversationMessages([notice], conversationID: id) + + #expect(controller.unreadBelowCount(in: id) == 2) + #expect(controller.unreadBelowCount(in: id) == controller.unreadBoundary(for: id).count) + #expect(controller.newestInboundMessageID(in: id) == MessageID(value: 3)) + } + @Test("an unknown read pointer counts nothing, as the divider does") func unknownPointer_countsNothing() async throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index c295bd65a..ca04d2163 100644 --- a/FlipcashTests/Chat/UnreadDividerTests.swift +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -127,7 +127,7 @@ struct UnreadDividerTests { id: MessageID(value: 2), senderID: nil, content: .system(fallbackText: "Alex joined"), - date: base.addingTimeInterval(120), + date: UnreadFixtures.base.addingTimeInterval(120), unreadSeq: 2 ) let messages = [message(1, fromSelf: false), notice, message(3, fromSelf: false)] From b2fc810c349f3fc2e45caa98561feb7dee217662 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 16:50:11 -0400 Subject: [PATCH 17/19] fix(chat): draw the jump-to-bottom button in SwiftUI beside the composer bar The button is a SwiftUI column hosted next to the bar and moves on the same spring as the bar's $ button, sharing one inset function. Its state lives in JumpToBottomModel instead of the UIKit controller. --- .../ChatScreenRepresentable.swift | 8 +- .../Conversation/ConversationBottomBar.swift | 58 +++++-- .../Chat/ChatScreenKeyboardFloorTests.swift | 21 --- .../Chat/JumpToBottomColumnTests.swift | 141 ++++++++++++++++++ .../Sources/FlipcashUI/Chat/ChatMotion.swift | 2 +- .../Chat/ChatScreenViewController.swift | 102 +++++-------- .../FlipcashUI/Chat/JumpToBottomModel.swift | 64 ++++++++ 7 files changed, 287 insertions(+), 109 deletions(-) create mode 100644 FlipcashTests/Chat/JumpToBottomColumnTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index af0fd3e51..213b8b1a7 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -151,7 +151,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder screen.barRestingDrop = BarMetrics.compactDrop - screen.barTrailingControlCentreInsets = BarMetrics.trailingControlCentreInsets screen.authorAvatars = authorAvatars screen.transcriptTopInset = transcriptTopInset screen.onReachTop = onReachTop @@ -198,6 +197,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onUserScrollBegan = onUserScrollBegan screen.reportsReads = reportsReads screen.update(items: items) + let jumpHost = UIHostingController(rootView: JumpToBottomColumn(jump: screen.jump, bar: barModel)) + jumpHost.view.backgroundColor = .clear + jumpHost.safeAreaRegions = [] + jumpHost.sizingOptions = [.intrinsicContentSize] + screen.installJumpColumn(jumpHost) context.coordinator.barHost = barHost context.coordinator.screen = screen context.coordinator.lastMessageID = lastMessageID(of: items) @@ -211,7 +215,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder screen.barRestingDrop = BarMetrics.compactDrop - screen.barTrailingControlCentreInsets = BarMetrics.trailingControlCentreInsets screen.transcriptTopInset = transcriptTopInset screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop @@ -364,7 +367,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } } .modifier(BarOverflowReporting(model: barModel) { [weak coordinator] in coordinator?.screen }) - .modifier(BarCompactReporting(model: barModel) { [weak coordinator] in coordinator?.screen }) ) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 11c137a34..cfd822dd9 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -165,16 +165,36 @@ struct BarOverflowReporting: ViewModifier { } } -/// Tells the screen when the bar switches between its compact and composing layouts, so the -/// jump-to-bottom button moves with the `$` button. -struct BarCompactReporting: ViewModifier { - let model: ConversationBarModel - let screen: () -> ChatScreenViewController? +/// The jump-to-bottom button's column, hosted beside the bar rather than in it: the bar's clip cuts +/// everything drawn above the bar's top edge, and the transcript's inset follows that edge. +/// +/// The column reads the same `isComposing` the bar's `$` does and moves on the same spring, so the +/// two stay stacked while the composer gains and loses focus. +struct JumpToBottomColumn: View { + let jump: JumpToBottomModel + let bar: ConversationBarModel - func body(content: Content) -> some View { - content.onChange(of: model.isComposing, initial: true) { _, isComposing in - screen()?.isBarCompact = !isComposing - } + /// Room for the badge overhanging the button's corner and for the button's travel between the + /// compact and composing columns. Taps in it, outside the button, reach the transcript. + static let size = CGSize(width: 88, height: 52) + + var body: some View { + let isShown = jump.isVisible + let isCompact = !bar.isComposing + // Centred on the trailing control's column, which is narrower than the button. + let inset = BarMetrics.trailingControlCentreInset(isCompact: isCompact) + - JumpToBottomButtonView.diameter / 2 + JumpToBottomButtonView(unreadCount: jump.unreadCount, action: jump.onTap) + .frame(width: JumpToBottomButtonView.diameter, height: JumpToBottomButtonView.diameter) + .opacity(isShown ? 1 : 0) + .scaleEffect(isShown ? 1 : 0.8) + // Hidden, the button also stops taking touches; opacity 0 alone would still be tappable. + .allowsHitTesting(isShown) + .accessibilityHidden(!isShown) + .animation(ChatMotion.reflow.animation, value: isShown) + .padding(.trailing, inset) + .animation(ChatMotion.barInset.animation, value: isCompact) + .frame(width: Self.size.width, height: Self.size.height, alignment: .bottomTrailing) } } @@ -213,12 +233,18 @@ enum BarMetrics { static let compactDrop: CGFloat = 8 /// The margin between the compact bar's controls and the screen's sides. static let compactInset: CGFloat = 32 - /// Distances from the screen's side to the centre of the bar's trailing control (Send Cash), while - /// composing and while compact. The jump-to-bottom button centres on this column. - static let trailingControlCentreInsets = ( - composing: edgeInset + contentHeight / 2, - compact: compactInset + contentHeight / 2 - ) + + /// The margin between the bar's controls and the screen's sides: ``compactInset`` while the bar + /// rests compact, ``edgeInset`` while composing. + static func controlInset(isCompact: Bool) -> CGFloat { + isCompact ? compactInset : edgeInset + } + + /// Distance from the screen's side to the centre of the bar's trailing control (Send Cash). The + /// jump-to-bottom button centres on this column. + static func trailingControlCentreInset(isCompact: Bool) -> CGFloat { + controlInset(isCompact: isCompact) + contentHeight / 2 + } } /// What stands outside the message field, beside it. @@ -360,7 +386,7 @@ struct ConversationBottomBar: View { private var isCompact: Bool { !model.isComposing } /// How much further in than ``BarMetrics/edgeInset`` the controls and the reply quote sit. - private var compactExtraInset: CGFloat { isCompact ? BarMetrics.compactInset - BarMetrics.edgeInset : 0 } + private var compactExtraInset: CGFloat { BarMetrics.controlInset(isCompact: isCompact) - BarMetrics.edgeInset } @Environment(\.accessibilityReduceMotion) private var reduceMotion diff --git a/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift b/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift index 073e1c2aa..251b10a0b 100644 --- a/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift +++ b/FlipcashTests/Chat/ChatScreenKeyboardFloorTests.swift @@ -114,27 +114,6 @@ struct ChatScreenKeyboardFloorTests { #expect(slack() == settled) } - @Test("The jump button's column follows the bar's compact state, not the keyboard") - func jumpButtonColumn_followsBarState() { - let (screen, _, window) = makeScreen() - screen.barTrailingControlCentreInsets = (composing: 37, compact: 57) - window.layoutIfNeeded() - - // Keyboard up while the bar is still compact: the bar has not moved its `$`, so neither - // does the jump button. - postKeyboard(endFrame: raisedKeyboard, isLocal: true) - window.layoutIfNeeded() - #expect(screen.jumpButtonCentreInset == 57) - - UIView.performWithoutAnimation { screen.isBarCompact = false } - #expect(screen.jumpButtonCentreInset == 37) - - // Keyboard down while the bar is still composing: the column stays with the bar. - postKeyboard(endFrame: raisedKeyboard.offsetBy(dx: 0, dy: keyboardHeight), isLocal: true) - window.layoutIfNeeded() - #expect(screen.jumpButtonCentreInset == 37) - } - private func firstCollectionView(in view: UIView) -> UICollectionView? { if let collectionView = view as? UICollectionView { return collectionView } return view.subviews.lazy.compactMap { firstCollectionView(in: $0) }.first diff --git a/FlipcashTests/Chat/JumpToBottomColumnTests.swift b/FlipcashTests/Chat/JumpToBottomColumnTests.swift new file mode 100644 index 000000000..54380fc5e --- /dev/null +++ b/FlipcashTests/Chat/JumpToBottomColumnTests.swift @@ -0,0 +1,141 @@ +// +// JumpToBottomColumnTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Testing +import UIKit +import FlipcashCore +@testable import Flipcash +@testable import FlipcashUI + +/// The jump-to-bottom column, driven by the model the screen fills in and by the bar's focus. +@MainActor +@Suite("Jump to bottom column") +struct JumpToBottomColumnTests { + + @MainActor + private final class Harness { + let jump = JumpToBottomModel() + let bar = ConversationBarModel() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let host: UIHostingController + var taps = 0 + + init() { + host = UIHostingController(rootView: JumpToBottomColumn(jump: jump, bar: bar)) + host.view.backgroundColor = .clear + host.safeAreaRegions = [] + jump.onTap = { [unowned self] in taps += 1 } + window.rootViewController = host + window.makeKeyAndVisible() + let size = JumpToBottomColumn.size + host.view.frame = CGRect(x: 390 - size.width, y: 700, width: size.width, height: size.height) + host.view.layoutIfNeeded() + } + + var button: JumpToBottomButton? { find(in: host.view) } + + private func find(in view: UIView) -> JumpToBottomButton? { + if let button = view as? JumpToBottomButton { return button } + return view.subviews.lazy.compactMap { self.find(in: $0) }.first + } + + /// The button's centre in the host's coordinates. + var buttonCentre: CGPoint { + guard let button else { return .zero } + return button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: host.view) + } + + /// Whether the button, or something inside it, is what a touch at `point` reaches. + func buttonTakesTouch(at point: CGPoint) -> Bool { + var view = host.view.hitTest(point, with: nil) + while let current = view { + if current is JumpToBottomButton { return true } + view = current.superview + } + return false + } + + /// Lets the springs finish. + func settle() async throws { + host.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(2000)) + host.view.layoutIfNeeded() + } + } + + @Test("Visibility set on the model shows the button and lets it take taps") + func visibility_drivesTheButton() async throws { + let harness = Harness() + try await harness.settle() + let button = try #require(harness.button) + + // Hidden: nothing takes the tap, though the button is still laid out there. + #expect(!harness.buttonTakesTouch(at: harness.buttonCentre)) + + harness.jump.isVisible = true + try await harness.settle() + #expect(harness.buttonTakesTouch(at: harness.buttonCentre)) + + button.sendActions(for: .touchUpInside) + #expect(harness.taps == 1) + + harness.jump.isVisible = false + try await harness.settle() + #expect(!harness.buttonTakesTouch(at: harness.buttonCentre)) + } + + @Test("The count set on the model is the badge") + func count_drivesTheBadge() async throws { + let harness = Harness() + harness.jump.isVisible = true + harness.jump.unreadCount = 3 + try await harness.settle() + let button = try #require(harness.button) + + #expect(button.accessibilityLabel == "Jump to latest message") + #expect(button.accessibilityValue == UnreadBadgeText.text(for: 3)) + + harness.jump.unreadCount = 0 + try await harness.settle() + #expect(button.accessibilityValue == nil) + } + + @Test("A touch beside the button, inside the column, falls through") + func touchBesideTheButton_fallsThrough() async throws { + let harness = Harness() + harness.jump.isVisible = true + try await harness.settle() + + let beside = CGPoint(x: harness.buttonCentre.x - JumpToButtonGeometry.radius - 8, y: harness.buttonCentre.y) + #expect(harness.host.view.bounds.contains(beside)) + #expect(!harness.buttonTakesTouch(at: beside)) + } + + @Test("The button rests over the bar's trailing control while compact") + func column_restsOverTheTrailingControl() async throws { + let harness = Harness() + harness.jump.isVisible = true + try await harness.settle() + let width = harness.host.view.bounds.width + + let compact = BarMetrics.trailingControlCentreInset(isCompact: true) + #expect(abs((width - harness.buttonCentre.x) - compact) < 0.5) + } + + @Test("The trailing control's centre inset is the control inset plus half its height") + func trailingControlCentreInset_values() { + // The composing column is asserted on the function: the spring that carries the view there + // does not advance in a test host with no display. + #expect(BarMetrics.trailingControlCentreInset(isCompact: true) == BarMetrics.compactInset + BarMetrics.contentHeight / 2) + #expect(BarMetrics.trailingControlCentreInset(isCompact: false) == BarMetrics.edgeInset + BarMetrics.contentHeight / 2) + } +} + +private enum JumpToButtonGeometry { + @MainActor static let radius = JumpToBottomButtonView.diameter / 2 +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index fd7b797f3..b60914799 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -138,7 +138,7 @@ public nonisolated enum ChatMotion { /// The bottom bar swapping between its action and composer states. public static let swap = ChatSpring(duration: 0.27, bounce: 0.31) /// The bar narrowing and widening as the composer gains and loses focus. The jump-to-bottom - /// button moves on the same curve, so it keeps its place over the `$` button. + /// column moves on the same curve, so it keeps its place over the `$` button. public static let barInset = ChatSpring(duration: 0.22, bounce: 0.14) /// The send arrow scaling in and out of the composer. public static let sendButton = ChatSpring(duration: 0.17, bounce: 0.34) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index f2f93d3c4..2988fcb1f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -109,7 +109,11 @@ public final class ChatScreenViewController: UIViewController { private let backdrop = MessageBackdrop() /// The blur over a transcript the user is not allowed to read. See `TranscriptBlur`. private let transcriptBlur = TranscriptBlur() - private let jumpButton = JumpToBottomButton() + /// What the jump-to-bottom button shows. The button itself is the column the owner installs with + /// ``installJumpColumn(_:)``. + public let jump = JumpToBottomModel() + /// The owner's hosted jump column, once placed. + private var jumpColumn: UIViewController? /// The decorative shapes the blur softens when there is no transcript to soften. See /// `GatePreviewPlaceholder`. private let gatePlaceholder = GatePreviewPlaceholder() @@ -389,7 +393,7 @@ public final class ChatScreenViewController: UIViewController { // Under the blur, so the shapes are only ever seen through it. gatePlaceholder.install(in: view, below: transcriptBlur.view) - installJumpButton() + placeJumpColumn() barClipHeightConstraint = constraints.clipHeight keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom, keyboardEdgeConstraint: constraints.keyboardEdge) keyboardFloor.restingDrop = barRestingDrop @@ -779,7 +783,7 @@ public final class ChatScreenViewController: UIViewController { shield.frame = transcript.view.frame shield.autoresizingMask = [.flexibleWidth, .flexibleHeight] // Above the jump button, so a tap while the attach keyboard is up dismisses it rather than jumping. - view.insertSubview(shield, aboveSubview: jumpButton) + view.insertSubview(shield, aboveSubview: jumpColumn?.view ?? transcript.view) transcriptShield = shield } @@ -929,81 +933,44 @@ public final class ChatScreenViewController: UIViewController { /// Inbound messages the viewer hasn't seen, shown on the jump-to-bottom button's badge. Zero /// hides the badge; the button itself shows whenever the reader is off the bottom. public var unreadBelowCount: Int = 0 { - didSet { jumpButton.setUnreadCount(unreadBelowCount) } + didSet { jump.unreadCount = unreadBelowCount } } /// Gap between the jump button and the composer's top edge. private static let jumpButtonGap: CGFloat = 20 - /// Where the jump button's centre sits from the screen's trailing edge when the owner has not - /// said where the bar's trailing control is. - private static let jumpButtonDefaultCentreInset: CGFloat = 16 + JumpToBottomButton.diameter / 2 - /// Distances from the screen's trailing edge to the centre of the bar's trailing control, while - /// the user is composing and while the bar rests compact. The jump button centres on that column - /// so the two stack. The bar is a hosted SwiftUI view, so the screen cannot constrain to the - /// control's view and takes the numbers from the bar's own layout constants instead. - public var barTrailingControlCentreInsets: (composing: CGFloat, compact: CGFloat)? { - didSet { refreshJumpButtonColumn() } - } - - /// Whether the bar rests compact: the state the bar's own layout switches on, which is the - /// composer's focus rather than the keyboard's visibility. The jump button follows it, on the - /// bar's own curve, so it stays over the `$` button. - public var isBarCompact = true { - didSet { - guard isBarCompact != oldValue else { return } - refreshJumpButtonColumn() - guard view.window != nil else { return } - ChatMotion.barInset.animate(options: [.beginFromCurrentState, .allowUserInteraction]) { - self.view.layoutIfNeeded() - } - } - } - private var jumpButtonCentre: NSLayoutConstraint? - - /// Distance from the trailing edge to the jump button's centre, as last set. - var jumpButtonCentreInset: CGFloat? { jumpButtonCentre.map { -$0.constant } } - private func refreshJumpButtonColumn() { - guard let jumpButtonCentre else { return } - let inset = barTrailingControlCentreInsets.map { isBarCompact ? $0.compact : $0.composing } - ?? Self.jumpButtonDefaultCentreInset - jumpButtonCentre.constant = -inset + /// Hands the screen the jump-to-bottom column to float over the transcript: the owner's hosted + /// SwiftUI view, drawn from ``jump`` and sized to its content. It stands above the bar's clip + /// rather than inside it (the clip cuts everything above the bar), under the blur over a + /// withheld transcript, so the button doesn't float sharp over text the reader can't read. + /// The column is bottom-trailing in its own frame; taps in the rest of it reach the transcript. + public func installJumpColumn(_ controller: UIViewController) { + if let jumpColumn { + jumpColumn.willMove(toParent: nil) + jumpColumn.view.removeFromSuperview() + jumpColumn.removeFromParent() + } + jumpColumn = controller + if isViewLoaded { placeJumpColumn() } } - /// Scale the button sits at while hidden. - private static let jumpHiddenScale: CGFloat = 0.8 - - /// Floats the jump button over the transcript, above the composer's clip rather than inside it - /// (the clip toggles `clipsToBounds`), under the blur over a withheld - /// transcript, so the button doesn't float sharp over text the reader can't read. - private func installJumpButton() { - jumpButton.translatesAutoresizingMaskIntoConstraints = false - view.insertSubview(jumpButton, belowSubview: transcriptBlur.view) - let centre = jumpButton.centerXAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.jumpButtonDefaultCentreInset) - jumpButtonCentre = centre - NSLayoutConstraint.activate([ - centre, - jumpButton.bottomAnchor.constraint(equalTo: barClip.topAnchor, constant: -Self.jumpButtonGap), - ]) - jumpButton.addAction(UIAction { [weak self] _ in self?.transcript.scrollToBottomFromJumpButton() }, for: .touchUpInside) - setJumpButton(visible: false, animated: false) + private func placeJumpColumn() { + jump.onTap = { [weak self] in self?.transcript.scrollToBottomFromJumpButton() } transcript.onJumpButtonVisibilityChange = { [weak self] visible in - self?.setJumpButton(visible: visible, animated: true) + self?.jump.isVisible = visible } + guard let jumpColumn else { return } + addChild(jumpColumn) + let column = jumpColumn.view! + column.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(column, belowSubview: transcriptBlur.view) + NSLayoutConstraint.activate([ + column.trailingAnchor.constraint(equalTo: view.trailingAnchor), + column.bottomAnchor.constraint(equalTo: barClip.topAnchor, constant: -Self.jumpButtonGap), + ]) + jumpColumn.didMove(toParent: self) } - /// Fades and scales the button in or out. Hidden, the button also stops taking - /// touches; alpha 0 alone would still be tappable. - private func setJumpButton(visible: Bool, animated: Bool) { - let scale = visible ? 1 : Self.jumpHiddenScale - let apply = { - self.jumpButton.alpha = visible ? 1 : 0 - self.jumpButton.transform = CGAffineTransform(scaleX: scale, y: scale) - } - jumpButton.isUserInteractionEnabled = visible - guard animated else { apply(); return } - ChatMotion.reflow.animate(apply) - } public func scrollToBottom(animated: Bool = true) { transcript.scrollToBottom(animated: animated) } /// Whether the transcript sits at its newest message — see ``ChatViewController/isAtBottom``. @@ -1021,7 +988,6 @@ public final class ChatScreenViewController: UIViewController { // pass later — and a bar moved after the pass leaves the transcript's inset below reading // the bar's old frame, with nothing to lay it out again. keyboardFloor.refresh() - refreshJumpButtonColumn() } public override func viewDidLayoutSubviews() { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift new file mode 100644 index 000000000..22adc73e6 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -0,0 +1,64 @@ +// +// JumpToBottomModel.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import Observation +import SwiftUI +import UIKit + +/// What the jump-to-bottom button shows, set by ``ChatScreenViewController`` from the transcript and +/// read by the column the owner hosts beside the bar. The button is drawn in SwiftUI so it can move +/// on the same spring as the bar's `$` button. +@MainActor @Observable +public final class JumpToBottomModel { + /// Whether the reader is off the bottom of a transcript whose opening scroll has landed. + public var isVisible = false + /// Inbound messages the reader hasn't seen; the badge hides at zero. + public var unreadCount = 0 + /// Scrolls the transcript to its newest message. + public var onTap: () -> Void = {} + + public init() {} +} + +/// The jump-to-bottom button: ``JumpToBottomButton`` in a SwiftUI view, so its frost, badge and +/// hairline are the ones the UIKit control draws. +public struct JumpToBottomButtonView: UIViewRepresentable { + + public static let diameter: CGFloat = JumpToBottomButton.diameter + + public let unreadCount: Int + public let action: () -> Void + + public init(unreadCount: Int, action: @escaping () -> Void) { + self.unreadCount = unreadCount + self.action = action + } + + public func makeCoordinator() -> Coordinator { Coordinator() } + + public func makeUIView(context: Context) -> UIView { + let button = JumpToBottomButton() + let coordinator = context.coordinator + button.addAction(UIAction { _ in coordinator.action() }, for: .touchUpInside) + return button + } + + public func updateUIView(_ view: UIView, context: Context) { + context.coordinator.action = action + (view as? JumpToBottomButton)?.setUnreadCount(unreadCount) + } + + public func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? { + CGSize(width: Self.diameter, height: Self.diameter) + } + + @MainActor public final class Coordinator { + var action: () -> Void = {} + } +} +#endif From e2e894ef79ced02844499eb68e7374902b5524da Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 21:23:39 -0400 Subject: [PATCH 18/19] fix(chat): give the jump-to-bottom button the composer's glass The button is now drawn in SwiftUI with the composer's glass, in a namespace of its own so it never merges with the $ button. The UIKit frost, hairline and shadow are gone, along with the UIKit control and its representable. --- .../Chat/JumpToBottomColumnTests.swift | 116 +------------- .../FlipcashUI/Chat/JumpToBottomButton.swift | 148 ------------------ .../FlipcashUI/Chat/JumpToBottomModel.swift | 62 +++++--- 3 files changed, 40 insertions(+), 286 deletions(-) delete mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift diff --git a/FlipcashTests/Chat/JumpToBottomColumnTests.swift b/FlipcashTests/Chat/JumpToBottomColumnTests.swift index 54380fc5e..19a50b8d9 100644 --- a/FlipcashTests/Chat/JumpToBottomColumnTests.swift +++ b/FlipcashTests/Chat/JumpToBottomColumnTests.swift @@ -12,121 +12,11 @@ import FlipcashCore @testable import Flipcash @testable import FlipcashUI -/// The jump-to-bottom column, driven by the model the screen fills in and by the bar's focus. +/// The jump-to-bottom column's place over the bar's trailing control. @MainActor @Suite("Jump to bottom column") struct JumpToBottomColumnTests { - @MainActor - private final class Harness { - let jump = JumpToBottomModel() - let bar = ConversationBarModel() - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) - let host: UIHostingController - var taps = 0 - - init() { - host = UIHostingController(rootView: JumpToBottomColumn(jump: jump, bar: bar)) - host.view.backgroundColor = .clear - host.safeAreaRegions = [] - jump.onTap = { [unowned self] in taps += 1 } - window.rootViewController = host - window.makeKeyAndVisible() - let size = JumpToBottomColumn.size - host.view.frame = CGRect(x: 390 - size.width, y: 700, width: size.width, height: size.height) - host.view.layoutIfNeeded() - } - - var button: JumpToBottomButton? { find(in: host.view) } - - private func find(in view: UIView) -> JumpToBottomButton? { - if let button = view as? JumpToBottomButton { return button } - return view.subviews.lazy.compactMap { self.find(in: $0) }.first - } - - /// The button's centre in the host's coordinates. - var buttonCentre: CGPoint { - guard let button else { return .zero } - return button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: host.view) - } - - /// Whether the button, or something inside it, is what a touch at `point` reaches. - func buttonTakesTouch(at point: CGPoint) -> Bool { - var view = host.view.hitTest(point, with: nil) - while let current = view { - if current is JumpToBottomButton { return true } - view = current.superview - } - return false - } - - /// Lets the springs finish. - func settle() async throws { - host.view.layoutIfNeeded() - try await Task.sleep(for: .milliseconds(2000)) - host.view.layoutIfNeeded() - } - } - - @Test("Visibility set on the model shows the button and lets it take taps") - func visibility_drivesTheButton() async throws { - let harness = Harness() - try await harness.settle() - let button = try #require(harness.button) - - // Hidden: nothing takes the tap, though the button is still laid out there. - #expect(!harness.buttonTakesTouch(at: harness.buttonCentre)) - - harness.jump.isVisible = true - try await harness.settle() - #expect(harness.buttonTakesTouch(at: harness.buttonCentre)) - - button.sendActions(for: .touchUpInside) - #expect(harness.taps == 1) - - harness.jump.isVisible = false - try await harness.settle() - #expect(!harness.buttonTakesTouch(at: harness.buttonCentre)) - } - - @Test("The count set on the model is the badge") - func count_drivesTheBadge() async throws { - let harness = Harness() - harness.jump.isVisible = true - harness.jump.unreadCount = 3 - try await harness.settle() - let button = try #require(harness.button) - - #expect(button.accessibilityLabel == "Jump to latest message") - #expect(button.accessibilityValue == UnreadBadgeText.text(for: 3)) - - harness.jump.unreadCount = 0 - try await harness.settle() - #expect(button.accessibilityValue == nil) - } - - @Test("A touch beside the button, inside the column, falls through") - func touchBesideTheButton_fallsThrough() async throws { - let harness = Harness() - harness.jump.isVisible = true - try await harness.settle() - - let beside = CGPoint(x: harness.buttonCentre.x - JumpToButtonGeometry.radius - 8, y: harness.buttonCentre.y) - #expect(harness.host.view.bounds.contains(beside)) - #expect(!harness.buttonTakesTouch(at: beside)) - } - - @Test("The button rests over the bar's trailing control while compact") - func column_restsOverTheTrailingControl() async throws { - let harness = Harness() - harness.jump.isVisible = true - try await harness.settle() - let width = harness.host.view.bounds.width - - let compact = BarMetrics.trailingControlCentreInset(isCompact: true) - #expect(abs((width - harness.buttonCentre.x) - compact) < 0.5) - } - @Test("The trailing control's centre inset is the control inset plus half its height") func trailingControlCentreInset_values() { // The composing column is asserted on the function: the spring that carries the view there @@ -135,7 +25,3 @@ struct JumpToBottomColumnTests { #expect(BarMetrics.trailingControlCentreInset(isCompact: false) == BarMetrics.edgeInset + BarMetrics.contentHeight / 2) } } - -private enum JumpToButtonGeometry { - @MainActor static let radius = JumpToBottomButtonView.diameter / 2 -} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift deleted file mode 100644 index 97cdd74c8..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomButton.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// JumpToBottomButton.swift -// FlipcashUI -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -#if canImport(UIKit) -import SwiftUI -import UIKit -import FlipcashCore - -/// A 44pt glass circle with a chevron and an unread badge that scrolls the transcript to its newest -/// message. White 12% over the chat's frosting, a hairline in the row-separator white, and a soft -/// shadow. -@MainActor -final class JumpToBottomButton: UIControl { - - static let diameter: CGFloat = 44 - - private let blur = UIVisualEffectView() - private let badge = BadgeView() - private var blurStrength: UIViewPropertyAnimator? - - init() { - super.init(frame: .zero) - layer.shadowColor = UIColor.black.cgColor - layer.shadowOpacity = 0.25 - layer.shadowRadius = 8 - layer.shadowOffset = CGSize(width: 0, height: 2) - - let disc = UIView() - disc.translatesAutoresizingMaskIntoConstraints = false - disc.isUserInteractionEnabled = false - disc.clipsToBounds = true - disc.layer.cornerRadius = Self.diameter / 2 - disc.layer.borderWidth = 0.5 - disc.layer.borderColor = UIColor(Color.rowSeparator).cgColor - addSubview(disc) - - let tint = UIView() - tint.translatesAutoresizingMaskIntoConstraints = false - tint.backgroundColor = UIColor.white.withAlphaComponent(0.12) - blur.translatesAutoresizingMaskIntoConstraints = false - disc.addSubview(blur) - disc.addSubview(tint) - blurStrength = ChatBackdrop.frost(blur) - - let chevron = UIImageView(image: UIImage( - systemName: "chevron.down", - withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) - )) - chevron.translatesAutoresizingMaskIntoConstraints = false - chevron.tintColor = .white - chevron.contentMode = .center - chevron.isUserInteractionEnabled = false - addSubview(chevron) - - badge.translatesAutoresizingMaskIntoConstraints = false - addSubview(badge) - - NSLayoutConstraint.activate([ - widthAnchor.constraint(equalToConstant: Self.diameter), - heightAnchor.constraint(equalToConstant: Self.diameter), - disc.topAnchor.constraint(equalTo: topAnchor), - disc.bottomAnchor.constraint(equalTo: bottomAnchor), - disc.leadingAnchor.constraint(equalTo: leadingAnchor), - disc.trailingAnchor.constraint(equalTo: trailingAnchor), - blur.topAnchor.constraint(equalTo: disc.topAnchor), - blur.bottomAnchor.constraint(equalTo: disc.bottomAnchor), - blur.leadingAnchor.constraint(equalTo: disc.leadingAnchor), - blur.trailingAnchor.constraint(equalTo: disc.trailingAnchor), - tint.topAnchor.constraint(equalTo: disc.topAnchor), - tint.bottomAnchor.constraint(equalTo: disc.bottomAnchor), - tint.leadingAnchor.constraint(equalTo: disc.leadingAnchor), - tint.trailingAnchor.constraint(equalTo: disc.trailingAnchor), - chevron.centerXAnchor.constraint(equalTo: centerXAnchor), - chevron.centerYAnchor.constraint(equalTo: centerYAnchor), - // The pill's centre sits near the circle's top-trailing corner, pulled in along the arc. - badge.centerXAnchor.constraint(equalTo: trailingAnchor, constant: -4), - badge.centerYAnchor.constraint(equalTo: topAnchor, constant: 4), - ]) - isAccessibilityElement = true - accessibilityTraits = .button - accessibilityLabel = "Jump to latest message" - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - isolated deinit { - blurStrength?.stopAnimation(true) - } - - /// Sets the badge from `count`; the badge hides at zero. - func setUnreadCount(_ count: Int) { - let text = UnreadBadgeText.text(for: count) - badge.isHidden = text == nil - badge.text = text - accessibilityValue = text - } - - /// The badge overhangs the circle's corner; a tap on the overhang is a tap on the button. - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - super.point(inside: point, with: event) || (!badge.isHidden && badge.frame.contains(point)) - } - - /// The pill: blue fill (18pt tall inside the ring), white semibold 11, and a 2pt ring in the chat - /// background that separates it from the circle. - private final class BadgeView: UIView { - private let label = UILabel() - - var text: String? { - get { label.text } - set { label.text = newValue } - } - - init() { - super.init(frame: .zero) - isUserInteractionEnabled = false - isHidden = true - backgroundColor = UIColor(Color.badgeFocus) - layer.borderWidth = 2 - layer.borderColor = UIColor(Color.backgroundMain).cgColor - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: 11, weight: .semibold) - label.textColor = .white - addSubview(label) - NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: centerXAnchor), - label.centerYAnchor.constraint(equalTo: centerYAnchor), - widthAnchor.constraint(greaterThanOrEqualTo: heightAnchor), - heightAnchor.constraint(equalToConstant: 22), - label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 5), - label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -5), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layoutSubviews() { - super.layoutSubviews() - layer.cornerRadius = bounds.height / 2 - } - } -} -#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift index 22adc73e6..97b3186f9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -8,7 +8,7 @@ #if canImport(UIKit) import Observation import SwiftUI -import UIKit +import FlipcashCore /// What the jump-to-bottom button shows, set by ``ChatScreenViewController`` from the transcript and /// read by the column the owner hosts beside the bar. The button is drawn in SwiftUI so it can move @@ -25,40 +25,56 @@ public final class JumpToBottomModel { public init() {} } -/// The jump-to-bottom button: ``JumpToBottomButton`` in a SwiftUI view, so its frost, badge and -/// hairline are the ones the UIKit control draws. -public struct JumpToBottomButtonView: UIViewRepresentable { +/// The jump-to-bottom button: a 44pt circle in the composer's glass, with a chevron and an unread badge. +public struct JumpToBottomButtonView: View { - public static let diameter: CGFloat = JumpToBottomButton.diameter + public static let diameter: CGFloat = 44 + private static let badgeHeight: CGFloat = 22 public let unreadCount: Int public let action: () -> Void + /// The button's own, so its glass never joins the `$` button's. + @Namespace private var glassNamespace + public init(unreadCount: Int, action: @escaping () -> Void) { self.unreadCount = unreadCount self.action = action } - public func makeCoordinator() -> Coordinator { Coordinator() } - - public func makeUIView(context: Context) -> UIView { - let button = JumpToBottomButton() - let coordinator = context.coordinator - button.addAction(UIAction { _ in coordinator.action() }, for: .touchUpInside) - return button - } - - public func updateUIView(_ view: UIView, context: Context) { - context.coordinator.action = action - (view as? JumpToBottomButton)?.setUnreadCount(unreadCount) - } - - public func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? { - CGSize(width: Self.diameter, height: Self.diameter) + public var body: some View { + let badgeText = UnreadBadgeText.text(for: unreadCount) + Button(action: action) { + Image(systemName: "chevron.down") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: Self.diameter, height: Self.diameter) + .composerGlass(in: Circle(), id: "jump", namespace: glassNamespace) + .composerRim(in: Circle()) + .contentShape(Circle()) + .overlay(alignment: .topTrailing) { + if let badgeText { + badge(badgeText) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Jump to latest message") + .accessibilityValue(badgeText ?? "") } - @MainActor public final class Coordinator { - var action: () -> Void = {} + /// The pill: blue fill, white semibold 11, and a 2pt ring in the chat background that separates it + /// from the circle. Its centre sits near the circle's top-trailing corner, pulled in along the arc. + private func badge(_ text: String) -> some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .frame(minWidth: Self.badgeHeight, minHeight: Self.badgeHeight) + .background(Color.badgeFocus, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.backgroundMain, lineWidth: 2)) + .alignmentGuide(.trailing) { $0.width / 2 + 4 } + .alignmentGuide(.top) { _ in Self.badgeHeight / 2 - 4 } } } #endif From c82e1914e63d6f80cc9b613c1ded61187039be4b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:39:25 -0400 Subject: [PATCH 19/19] fix(chat): draw the jump-to-bottom button inside the composer bar's hierarchy The button was a second hosting controller beside the bar, so it ran a frame ahead of the $ button on every keyboard transition and a second composer row showed above the real one. It is now an overlay in ConversationBottomBar, and the bar's clip leaves its travel area uncut without growing the transcript inset or keyboard floor. --- .../ChatScreenRepresentable.swift | 12 +- .../Conversation/ConversationBottomBar.swift | 44 ++++--- .../Chat/ChatScreenBarClipTests.swift | 38 ++++++ .../Chat/JumpToBottomColumnTests.swift | 9 ++ .../Chat/ChatScreenViewController.swift | 122 ++++++++++++------ .../FlipcashUI/Chat/JumpToBottomModel.swift | 13 +- 6 files changed, 173 insertions(+), 65 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 213b8b1a7..4d259e9e4 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -137,6 +137,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var transcriptTopInset: CGFloat = 0 func makeUIViewController(context: Context) -> ChatScreenViewController { + let jump = JumpToBottomModel() + context.coordinator.jump = jump let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator)) barHost.view.backgroundColor = .clear // The bar's content is pinned to the bottom of this view and overhangs the top while the @@ -146,7 +148,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { // indicator's safe area. Left to respect that safe area, the bar pushes its content up by // however far it rests into it, past the clip's top edge. barHost.safeAreaRegions = [] - let screen = ChatScreenViewController(bar: barHost.view, barController: barHost) + let screen = ChatScreenViewController(bar: barHost.view, barController: barHost, jump: jump) screen.focusesComposerOnAppear = focusOnAppear screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder @@ -197,11 +199,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onUserScrollBegan = onUserScrollBegan screen.reportsReads = reportsReads screen.update(items: items) - let jumpHost = UIHostingController(rootView: JumpToBottomColumn(jump: screen.jump, bar: barModel)) - jumpHost.view.backgroundColor = .clear - jumpHost.safeAreaRegions = [] - jumpHost.sizingOptions = [.intrinsicContentSize] - screen.installJumpColumn(jumpHost) context.coordinator.barHost = barHost context.coordinator.screen = screen context.coordinator.lastMessageID = lastMessageID(of: items) @@ -330,6 +327,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onSendCash: onSendCash, model: barModel, composer: composer, + jump: coordinator.jump, gate: gate, gateMintName: gateMintName, gateShortfall: gateShortfall, @@ -374,6 +372,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { @MainActor final class Coordinator { var barHost: UIHostingController? + /// What the bar's jump-to-bottom button shows; handed to the screen that drives it. + var jump: JumpToBottomModel? weak var screen: ChatScreenViewController? let model: ConversationBarModel let composer: ComposerModel diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index cfd822dd9..1e60896bf 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -165,36 +165,33 @@ struct BarOverflowReporting: ViewModifier { } } -/// The jump-to-bottom button's column, hosted beside the bar rather than in it: the bar's clip cuts -/// everything drawn above the bar's top edge, and the transcript's inset follows that edge. +/// The jump-to-bottom button, drawn in the bar's own hierarchy above its trailing control so the one +/// transaction that moves `$` with the keyboard moves the button too. /// -/// The column reads the same `isComposing` the bar's `$` does and moves on the same spring, so the -/// two stay stacked while the composer gains and loses focus. -struct JumpToBottomColumn: View { +/// It stands outside the bar's measured height, so the transcript's inset and the keyboard floor +/// never see it; the screen's clip leaves the area it travels uncut. +struct JumpToBottomOverlay: View { let jump: JumpToBottomModel - let bar: ConversationBarModel - - /// Room for the badge overhanging the button's corner and for the button's travel between the - /// compact and composing columns. Taps in it, outside the button, reach the transcript. - static let size = CGSize(width: 88, height: 52) + let isCompact: Bool + /// Whether the attach panel is up and takes every touch outside itself, the button's included. + let takesTouches: Bool var body: some View { - let isShown = jump.isVisible - let isCompact = !bar.isComposing + let isShown = jump.isShown // Centred on the trailing control's column, which is narrower than the button. let inset = BarMetrics.trailingControlCentreInset(isCompact: isCompact) - JumpToBottomButtonView.diameter / 2 JumpToBottomButtonView(unreadCount: jump.unreadCount, action: jump.onTap) .frame(width: JumpToBottomButtonView.diameter, height: JumpToBottomButtonView.diameter) + // Opacity only: a glass effect under a scale effect drew a shrunken copy of the screen. .opacity(isShown ? 1 : 0) - .scaleEffect(isShown ? 1 : 0.8) // Hidden, the button also stops taking touches; opacity 0 alone would still be tappable. - .allowsHitTesting(isShown) + .allowsHitTesting(isShown && takesTouches) .accessibilityHidden(!isShown) .animation(ChatMotion.reflow.animation, value: isShown) .padding(.trailing, inset) .animation(ChatMotion.barInset.animation, value: isCompact) - .frame(width: Self.size.width, height: Self.size.height, alignment: .bottomTrailing) + .offset(y: -(JumpToBottomButtonView.diameter + BarMetrics.jumpButtonGapAboveBar)) } } @@ -233,6 +230,14 @@ enum BarMetrics { static let compactDrop: CGFloat = 8 /// The margin between the compact bar's controls and the screen's sides. static let compactInset: CGFloat = 32 + /// The gap between the field and the `$` beside it, the same compact and composing. + nonisolated static let controlSpacing: CGFloat = 10 + /// The gap between the jump-to-bottom button's bottom edge and the top edge of `$`: twice the + /// field's gap to `$`. + static let jumpButtonGap: CGFloat = controlSpacing * 2 + /// How far above the bar's top edge the button's bottom edge stands, which is `$`'s top edge + /// less the bar's own margin above its controls. + static let jumpButtonGapAboveBar: CGFloat = jumpButtonGap - contentPadding /// The margin between the bar's controls and the screen's sides: ``compactInset`` while the bar /// rests compact, ``edgeInset`` while composing. @@ -311,6 +316,8 @@ struct ConversationBottomBar: View { let onSendCash: () -> Void let model: ConversationBarModel let composer: ComposerModel + /// What the jump-to-bottom button shows, or `nil` where the bar has none. + var jump: JumpToBottomModel? = nil /// Whether the chat's participation rules leave this user anything to type. Anything but /// ``ConversationGatePresentation/open`` replaces the whole composer with the gate panel. var gate: ConversationGatePresentation = .open @@ -376,7 +383,7 @@ struct ConversationBottomBar: View { .combined(with: .scale(scale: 0.4)) /// The gap between the field and the control beside it: cancel-edit on its left, `$` on its right. The composer's glass joins across no /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. - static let leadingSpacing: CGFloat = 10 + static let leadingSpacing: CGFloat = BarMetrics.controlSpacing /// How close the composer's glass shapes come before they flow together: under the resting gap, /// so `$` is bridged to the field while it travels and stands clear of it at rest. private static let glassJoinDistance: CGFloat = 8 @@ -625,6 +632,11 @@ struct ConversationBottomBar: View { .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { composerRowHeight = $0 } .preference(key: BarAccessoriesKey.self, value: BarAccessories(restHeight: composerRowHeight)) } + .overlay(alignment: .topTrailing) { + if let jump { + JumpToBottomOverlay(jump: jump, isCompact: isCompact, takesTouches: !model.attachPanel.isOpen) + } + } .onChange(of: mentions == nil ? nil : composer.mentionQuery?.text, initial: true) { _, query in mentions?.update(query: query) } diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index 9f6471cb9..2e5025829 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -183,6 +183,44 @@ struct ChatScreenBarClipTests { #expect(clipHeight(bar, window) == composerRow) } + @Test("The jump button showing leaves the clip, the transcript's inset and the keyboard floor alone") + func jumpButtonShowing_doesNotGrowTheInsetOrTheClip() throws { + let (screen, bar, window) = makeScreen() + let transcript = try #require(screen.view.firstSubview(of: UICollectionView.self)) + let clip = try #require(bar.superview) + + report(screen, composerRow) + window.layoutIfNeeded() + let inset = transcript.contentInset.bottom + let clipFrame = clip.frame + + screen.jump.isVisible = true + window.layoutIfNeeded() + report(screen, composerRow) + window.layoutIfNeeded() + + #expect(screen.jump.isShown) + #expect(transcript.contentInset.bottom == inset) + #expect(clip.frame == clipFrame, "The clip, which rides the keyboard floor, is the composer's alone") + } + + @Test("Above the clip, only the button's band takes touches") + func jumpBand_takesTouchesAboveTheClipOnlyInItsBand() throws { + let (_, bar, _) = makeScreen() + let clip = try #require(bar.superview) + let reach = JumpToBottomButtonView.reach + + let inBand = CGPoint(x: clip.bounds.width - reach.width / 2, y: -reach.height / 2) + #expect(clip.point(inside: inBand, with: nil)) + + let beside = CGPoint(x: clip.bounds.width - reach.width - 30, y: -reach.height / 2) + #expect(!clip.point(inside: beside, with: nil), "A tap beside the button belongs to the transcript") + #expect(clip.hitTest(beside, with: nil) == nil) + + let above = CGPoint(x: clip.bounds.width - reach.width / 2, y: -reach.height - 1) + #expect(!clip.point(inside: above, with: nil)) + } + @Test("The room reported leaves the list out and counts the reply strip") func mentionRoom_excludesTheListAndCountsTheStrip() { let (screen, _, window) = makeScreen() diff --git a/FlipcashTests/Chat/JumpToBottomColumnTests.swift b/FlipcashTests/Chat/JumpToBottomColumnTests.swift index 19a50b8d9..8227f31dd 100644 --- a/FlipcashTests/Chat/JumpToBottomColumnTests.swift +++ b/FlipcashTests/Chat/JumpToBottomColumnTests.swift @@ -24,4 +24,13 @@ struct JumpToBottomColumnTests { #expect(BarMetrics.trailingControlCentreInset(isCompact: true) == BarMetrics.compactInset + BarMetrics.contentHeight / 2) #expect(BarMetrics.trailingControlCentreInset(isCompact: false) == BarMetrics.edgeInset + BarMetrics.contentHeight / 2) } + + @Test("The button stands twice the field-to-$ gap above $, whichever way the bar rests") + func jumpButtonGap_isTwiceTheFieldGap() { + #expect(ConversationBottomBar.leadingSpacing == BarMetrics.controlSpacing) + #expect(BarMetrics.jumpButtonGap == BarMetrics.controlSpacing * 2) + // Measured from the bar's top edge, which stands the bar's margin above `$`. + #expect(BarMetrics.jumpButtonGapAboveBar + BarMetrics.contentPadding == BarMetrics.jumpButtonGap) + #expect(JumpToBottomButtonView.reach.height >= JumpToBottomButtonView.diameter + BarMetrics.jumpButtonGapAboveBar) + } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 2988fcb1f..d2fa3bdf1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -68,7 +68,7 @@ public final class ChatScreenViewController: UIViewController { guard barOverflowsTop != oldValue, barClipHeightConstraint != nil else { return } // The bar is already as tall as the screen and pinned to the clip's bottom, so letting // it draw and take touches above the clip is all the overflow needs. - barClip.clipsToBounds = !barOverflowsTop + barClip.clipsBar = !barOverflowsTop barClip.overflowTarget = barOverflowsTop ? bar : nil } } @@ -92,7 +92,10 @@ public final class ChatScreenViewController: UIViewController { /// unmet. The bar is left sharp and keeps its own height, so the gate panel the caller hosts in /// place of the composer measures and reserves transcript inset exactly as the composer does. public var isTranscriptObscured = false { - didSet { transcriptBlur.isShown = isTranscriptObscured } + didSet { + transcriptBlur.isShown = isTranscriptObscured + updateJumpSuppression() + } } /// Whether to draw the gate's decorative shapes behind the blur — set when the chat is @@ -109,11 +112,8 @@ public final class ChatScreenViewController: UIViewController { private let backdrop = MessageBackdrop() /// The blur over a transcript the user is not allowed to read. See `TranscriptBlur`. private let transcriptBlur = TranscriptBlur() - /// What the jump-to-bottom button shows. The button itself is the column the owner installs with - /// ``installJumpColumn(_:)``. - public let jump = JumpToBottomModel() - /// The owner's hosted jump column, once placed. - private var jumpColumn: UIViewController? + /// What the jump-to-bottom button shows. The bar draws the button, above its trailing control. + public let jump: JumpToBottomModel /// The decorative shapes the blur softens when there is no transcript to soften. See /// `GatePreviewPlaceholder`. private let gatePlaceholder = GatePreviewPlaceholder() @@ -161,7 +161,9 @@ public final class ChatScreenViewController: UIViewController { /// - barController: the view controller owning the bar, when hosted (e.g. a /// `UIHostingController` for a SwiftUI bar). Adopted as a child so its lifecycle and /// environment work. Pass `nil` for a plain `UIView` bar. - public init(bar: UIView, barController: UIViewController? = nil) { + /// - jump: the model the bar draws its jump-to-bottom button from; this screen drives it. + public init(bar: UIView, barController: UIViewController? = nil, jump: JumpToBottomModel = JumpToBottomModel()) { + self.jump = jump self.bar = bar self.barController = barController super.init(nibName: nil, bundle: nil) @@ -393,7 +395,8 @@ public final class ChatScreenViewController: UIViewController { // Under the blur, so the shapes are only ever seen through it. gatePlaceholder.install(in: view, below: transcriptBlur.view) - placeJumpColumn() + wireJumpButton() + barClip.overhang = JumpToBottomButtonView.reach barClipHeightConstraint = constraints.clipHeight keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom, keyboardEdgeConstraint: constraints.keyboardEdge) keyboardFloor.restingDrop = barRestingDrop @@ -450,6 +453,7 @@ public final class ChatScreenViewController: UIViewController { } overlay.onDismissRequest = { [weak self] in self?.dismissLift() } lift = (overlay, message) + updateJumpSuppression() backdrop.present(over: contextMenuBackdropHost) if let responder = bar.firstTextInputResponder { @@ -464,6 +468,7 @@ public final class ChatScreenViewController: UIViewController { private func dismissLift() { guard let (overlay, _) = lift else { return } lift = nil + updateJumpSuppression() let copy = overlay.dismiss(landingIn: transcript.liftLanding()) { [weak self] in self?.transcript.endLift() } @@ -485,6 +490,7 @@ public final class ChatScreenViewController: UIViewController { /// as the lifted preview and the cell underneath is hidden. public func beginEditSpotlight(for stableID: String) { editedStableID = stableID + updateJumpSuppression() backdrop.present(over: contextMenuBackdropHost) backdrop.hold(clearing: barClip, under: hostNavigationController?.navigationBar) setPopGesturesSuspended(true) @@ -498,6 +504,7 @@ public final class ChatScreenViewController: UIViewController { public func endEditSpotlight() { guard editedStableID != nil else { return } editedStableID = nil + updateJumpSuppression() spotlightAttemptsRemaining = 0 setPopGesturesSuspended(false) backdrop.release() @@ -579,7 +586,7 @@ public final class ChatScreenViewController: UIViewController { ) -> (clipHeight: NSLayoutConstraint, bottom: NSLayoutConstraint, keyboardEdge: NSLayoutConstraint) { if let controller { addChild(controller) } barClip.translatesAutoresizingMaskIntoConstraints = false - barClip.clipsToBounds = true + barClip.clipsBar = true view.addSubview(barClip) let keyboardEdge = addComposerFade(below: barClip) addKeyboardCornerCover(below: barClip) @@ -782,8 +789,7 @@ public final class ChatScreenViewController: UIViewController { shield.onTap = onTap shield.frame = transcript.view.frame shield.autoresizingMask = [.flexibleWidth, .flexibleHeight] - // Above the jump button, so a tap while the attach keyboard is up dismisses it rather than jumping. - view.insertSubview(shield, aboveSubview: jumpColumn?.view ?? transcript.view) + view.insertSubview(shield, aboveSubview: transcript.view) transcriptShield = shield } @@ -936,39 +942,17 @@ public final class ChatScreenViewController: UIViewController { didSet { jump.unreadCount = unreadBelowCount } } - /// Gap between the jump button and the composer's top edge. - private static let jumpButtonGap: CGFloat = 20 - - /// Hands the screen the jump-to-bottom column to float over the transcript: the owner's hosted - /// SwiftUI view, drawn from ``jump`` and sized to its content. It stands above the bar's clip - /// rather than inside it (the clip cuts everything above the bar), under the blur over a - /// withheld transcript, so the button doesn't float sharp over text the reader can't read. - /// The column is bottom-trailing in its own frame; taps in the rest of it reach the transcript. - public func installJumpColumn(_ controller: UIViewController) { - if let jumpColumn { - jumpColumn.willMove(toParent: nil) - jumpColumn.view.removeFromSuperview() - jumpColumn.removeFromParent() - } - jumpColumn = controller - if isViewLoaded { placeJumpColumn() } - } - - private func placeJumpColumn() { + private func wireJumpButton() { jump.onTap = { [weak self] in self?.transcript.scrollToBottomFromJumpButton() } transcript.onJumpButtonVisibilityChange = { [weak self] visible in self?.jump.isVisible = visible } - guard let jumpColumn else { return } - addChild(jumpColumn) - let column = jumpColumn.view! - column.translatesAutoresizingMaskIntoConstraints = false - view.insertSubview(column, belowSubview: transcriptBlur.view) - NSLayoutConstraint.activate([ - column.trailingAnchor.constraint(equalTo: view.trailingAnchor), - column.bottomAnchor.constraint(equalTo: barClip.topAnchor, constant: -Self.jumpButtonGap), - ]) - jumpColumn.didMove(toParent: self) + } + + /// Keeps the button off while the transcript is blurred or a message is lifted or spotlighted: + /// the bar stands above those, so the button would be drawn over them. + private func updateJumpSuppression() { + jump.isSuppressed = isTranscriptObscured || lift != nil || editedStableID != nil } public func scrollToBottom(animated: Bool = true) { transcript.scrollToBottom(animated: animated) } @@ -1169,6 +1153,54 @@ private final class BarClipView: UIView { /// Where, in window coordinates, the target starts taking those touches; `nil` for all of it. var overflowTouchTop: CGFloat? + /// The area above the box's top edge, from its trailing side, that is left uncut and takes + /// touches: where the bar draws its jump-to-bottom button. Touches in it that the bar's content + /// does not claim fall through to what is behind the box. + var overhang: CGSize = .zero { + didSet { applyClipping() } + } + + /// Whether the bar is cut at the box's edges. Off while the bar overflows. + var clipsBar = true { + didSet { applyClipping() } + } + + // A mask view rather than a layer: its frame animates with the box when the height constraint + // changes inside an animation block, where a bare layer's would trail it. + private let maskContainer = UIView() + private let maskBody = UIView() + private let maskOverhang = UIView() + + private func applyClipping() { + if clipsBar, overhang != .zero { + if maskContainer.superview == nil { + maskContainer.backgroundColor = .clear + for part in [maskBody, maskOverhang] { + part.backgroundColor = .black + maskContainer.addSubview(part) + } + } + clipsToBounds = false + mask = maskContainer + setNeedsLayout() + } else { + mask = nil + clipsToBounds = clipsBar + } + } + + override func layoutSubviews() { + super.layoutSubviews() + guard mask === maskContainer else { return } + maskContainer.frame = bounds + maskBody.frame = bounds + maskOverhang.frame = CGRect(x: bounds.width - overhang.width, y: -overhang.height, width: overhang.width, height: overhang.height) + } + + private var overhangRect: CGRect { + CGRect(x: bounds.width - overhang.width, y: -overhang.height, width: overhang.width, height: overhang.height) + } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { if let overflowTarget { var region = overflowTarget.frame @@ -1178,8 +1210,16 @@ private final class BarClipView: UIView { } if region.contains(point) { return true } } + if clipsBar, overhang != .zero, overhangRect.contains(point) { return true } return super.point(inside: point, with: event) } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + // The box itself is never what a touch above it lands on. + if hit === self, !bounds.contains(point) { return nil } + return hit + } } /// A clear layer that swallows touches while its predicate holds and passes them through otherwise. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift index 97b3186f9..e068b71d3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -11,24 +11,33 @@ import SwiftUI import FlipcashCore /// What the jump-to-bottom button shows, set by ``ChatScreenViewController`` from the transcript and -/// read by the column the owner hosts beside the bar. The button is drawn in SwiftUI so it can move -/// on the same spring as the bar's `$` button. +/// read by the bar, which draws the button above its trailing control so one transaction moves both. @MainActor @Observable public final class JumpToBottomModel { /// Whether the reader is off the bottom of a transcript whose opening scroll has landed. public var isVisible = false + /// Whether something drawn over the transcript (a blur, a spotlighted edit) means the button must + /// not show, since the bar's view stands above those. + public var isSuppressed = false /// Inbound messages the reader hasn't seen; the badge hides at zero. public var unreadCount = 0 /// Scrolls the transcript to its newest message. public var onTap: () -> Void = {} public init() {} + + /// Whether the button shows and takes taps. + public var isShown: Bool { isVisible && !isSuppressed } } /// The jump-to-bottom button: a 44pt circle in the composer's glass, with a chevron and an unread badge. public struct JumpToBottomButtonView: View { public static let diameter: CGFloat = 44 + /// The area above the bar's top edge, from its trailing side, that the button and its badge + /// occupy, with room to spare for the gap the bar leaves under it. The bar's clip leaves it + /// uncut and lets touches in it through to the button. + public static let reach = CGSize(width: 88, height: 20 + diameter + 12) private static let badgeHeight: CGFloat = 22 public let unreadCount: Int