diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 60094bb3d..b221ff49d 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 @@ -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() { @@ -1911,13 +1915,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 +1929,35 @@ 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. + 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 + } + + /// 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. + 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:)) @@ -1959,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. @@ -1991,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 @@ -2165,6 +2216,12 @@ 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. + // 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 @@ -2494,6 +2551,10 @@ final class ConversationController { scheduleGapCatchUp(conversationID) } store.advanceLastActivity(to: message.date, in: conversationID) + // 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) @@ -2518,16 +2579,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 = [ - store.selfReadPointer(for: conversationID, selfUserID: selfUserID), - store.unsyncedSelfReadPointers[conversationID], - ].compactMap { $0 }.max() - if let previous, messageID <= previous { return } + let previous = readThrough(for: conversationID) + 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. @@ -2535,6 +2596,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/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index eec53abbd..4d259e9e4 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -58,6 +58,10 @@ 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 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 @@ -133,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 @@ -142,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 @@ -189,6 +195,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCancelEdit = { [composer] in composer.endEditing() } 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 @@ -239,6 +247,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCancelEdit = { [composer] in composer.endEditing() } 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 @@ -317,6 +327,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onSendCash: onSendCash, model: barModel, composer: composer, + jump: coordinator.jump, gate: gate, gateMintName: gateMintName, gateShortfall: gateShortfall, @@ -361,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 29ad0b28b..1e60896bf 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -165,6 +165,36 @@ struct BarOverflowReporting: ViewModifier { } } +/// 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. +/// +/// 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 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.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) + // Hidden, the button also stops taking touches; opacity 0 alone would still be tappable. + .allowsHitTesting(isShown && takesTouches) + .accessibilityHidden(!isShown) + .animation(ChatMotion.reflow.animation, value: isShown) + .padding(.trailing, inset) + .animation(ChatMotion.barInset.animation, value: isCompact) + .offset(y: -(JumpToBottomButtonView.diameter + BarMetrics.jumpButtonGapAboveBar)) + } +} + /// Single spring driving the bar's morphs: the focus-driven resize and the send-arrow pop. private let barMorphSpring = ChatMotion.swap.animation @@ -200,6 +230,26 @@ 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. + 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. @@ -266,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 @@ -319,7 +371,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) @@ -331,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 @@ -341,7 +393,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 @@ -580,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/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index d975fcf6e..719d2af48 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? @@ -84,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 @@ -95,6 +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, 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 2481d58dd..7ae0ddf73 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 @@ -407,6 +409,8 @@ struct ConversationScreen: View { onQuoteTap: jumpToQuote, onMessagesSeen: markSeen, reportsReads: reportsReads(gate: gate), + unreadBelowCount: coordinator?.jumpBadge.count ?? 0, + onUserScrollBegan: { coordinator?.jumpBadge.userDidScroll() }, onReactionTap: toggleReaction, onReactionLongPress: openReactors, onReactionAdd: openReactionPicker, @@ -1249,7 +1253,16 @@ 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) + 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. await pushController.clearDeliveredNotifications(for: conversationID) @@ -1429,7 +1442,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 new file mode 100644 index 000000000..50c0c8fd3 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/JumpBadgeCount.swift @@ -0,0 +1,87 @@ +// +// 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. 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 { + + @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 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? + /// 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. + /// `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 + self.landingsAtOpen = controller.newestPageLandings[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 + /// 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 + seatCutoffFromLanding() + 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) + } + + /// 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 } + didScroll = true + } +} 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/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 595f9bfcb..816bf31cf 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -272,6 +272,20 @@ 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, 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]) } + } + /// 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/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/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/JumpBadgeCountTests.swift b/FlipcashTests/Chat/JumpBadgeCountTests.swift new file mode 100644 index 000000000..cf78bd805 --- /dev/null +++ b/FlipcashTests/Chat/JumpBadgeCountTests.swift @@ -0,0 +1,303 @@ +// +// JumpBadgeCountTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import UIKit +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) + badge.openingLoadLanded() + 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) + badge.openingLoadLanded() + + controller.persist(operation: "upsert-messages") { + try database.upsertConversationMessages([message(5, fromSelf: true)], conversationID: id) + } + + #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) + } + + @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) + } + + @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/Chat/JumpToBottomColumnTests.swift b/FlipcashTests/Chat/JumpToBottomColumnTests.swift new file mode 100644 index 000000000..8227f31dd --- /dev/null +++ b/FlipcashTests/Chat/JumpToBottomColumnTests.swift @@ -0,0 +1,36 @@ +// +// 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's place over the bar's trailing control. +@MainActor +@Suite("Jump to bottom column") +struct JumpToBottomColumnTests { + + @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) + } + + @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/FlipcashTests/Chat/UnreadBelowCountTests.swift b/FlipcashTests/Chat/UnreadBelowCountTests.swift new file mode 100644 index 000000000..852eef26b --- /dev/null +++ b/FlipcashTests/Chat/UnreadBelowCountTests.swift @@ -0,0 +1,171 @@ +// +// 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 + ) + #expect(controller.unreadBelowCount(in: id) == 3) + + controller.advanceReadPointer(to: MessageID(value: 2), in: id) + + #expect(controller.unreadBelowCount(in: id) == 2) + } + + @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) } + let controller = try await UnreadFixtures.hydratedController( + messages: [message(1, fromSelf: false), message(2, fromSelf: false)], + pointer: 1, database: database + ) + 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) + } + + @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() + 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("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() + 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) + } +} + +/// 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/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index 8fe9c7378..ca04d2163 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. @@ -159,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)] 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 new file mode 100644 index 000000000..d1d63d27e --- /dev/null +++ b/FlipcashTests/TestSupport/UnreadFixtures.swift @@ -0,0 +1,68 @@ +// +// 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. `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, + hasConversationRow: Bool = true, + type: ConversationType = .contactDm, + mock: MockConversations = MockConversations(), + receipts: ConversationReceiptReporter? = nil + ) async throws -> ConversationController { + var members = [ConversationMember(userID: them, displayName: "Them")] + if let pointer { + members.append(ConversationMember(userID: me, displayName: "", readPointer: MessageID(value: pointer))) + } + if hasConversationRow { + try database.upsertConversation( + Conversation(id: .test(1), members: members, lastMessage: nil, lastActivity: base, type: type) + ) + } + try database.upsertConversationMessages(messages, conversationID: .test(1)) + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: me, + receipts: receipts + ) + await controller.hydrateFromDatabase() + return controller + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index b84749603..b60914799 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 + /// 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) /// 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 9b0a77406..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,6 +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 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() @@ -156,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) @@ -294,6 +301,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 } @@ -382,6 +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) + wireJumpButton() + barClip.overhang = JumpToBottomButtonView.reach barClipHeightConstraint = constraints.clipHeight keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom, keyboardEdgeConstraint: constraints.keyboardEdge) keyboardFloor.restingDrop = barRestingDrop @@ -438,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 { @@ -452,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() } @@ -473,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) @@ -486,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() @@ -567,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) @@ -916,6 +935,26 @@ 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 { jump.unreadCount = unreadBelowCount } + } + + private func wireJumpButton() { + jump.onTap = { [weak self] in self?.transcript.scrollToBottomFromJumpButton() } + transcript.onJumpButtonVisibilityChange = { [weak self] visible in + self?.jump.isVisible = visible + } + } + + /// 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) } /// Whether the transcript sits at its newest message — see ``ChatViewController/isAtBottom``. @@ -1114,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 @@ -1123,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/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index ce7e138ee..b32599472 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,18 @@ 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)? + /// 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() /// Whether a read report is queued for the next main-queue turn, so a burst of scroll ticks @@ -924,6 +937,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 +948,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 +1027,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 +1037,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 +1051,64 @@ 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 scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + 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() // 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 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift new file mode 100644 index 000000000..e068b71d3 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -0,0 +1,89 @@ +// +// JumpToBottomModel.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import Observation +import SwiftUI +import FlipcashCore + +/// What the jump-to-bottom button shows, set by ``ChatScreenViewController`` from the transcript and +/// 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 + 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 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 ?? "") + } + + /// 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 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 }