From ec2f7dbdc440f5efe5de4cff94e7500a4967b7cf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 16:48:22 -0400 Subject: [PATCH 1/4] feat(chat): mark where unread messages start with a divider Opening a chat now draws an "N Unread Messages" band above the first message someone else sent after the viewer's READ pointer, matching Android. The boundary is read once, in the load coordinator's init, before the opening task's markRead advances the pointer, and later arrivals do not move it or change its count. A missing self member row resolves to no divider rather than treating the whole transcript as unread, which is what hasUnread would do: a group roster is paged, so the row can be absent. With more unread than fits on screen the chat opens with the divider at the top of the visible area; otherwise it opens at the bottom as before. The loader grows its window back to the read-through message when there are at most 200 unread. UnreadDividerLifetime.current keeps the divider until the chat closes. Setting it to .untilSend clears it on the viewer's first send instead. UnreadDividerTests encodes the table of cases Android shares, run through the database, the stored member row and the mapper. Placement follows the spec's "Own message first after pointer" row, which its pairwise formula cannot produce: the divider heads the first message after the pointer that someone else sent, found by id range so a deleted read-through message still places it. --- .../Controllers/ConversationController.swift | 14 ++ .../Conversation/ChatItem+Conversation.swift | 18 +- .../ConversationLoadCoordinator.swift | 51 +++- .../FlipcashCore/Models/Chat/ChatItem.swift | 24 +- .../Models/Conversation/UnreadBoundary.swift | 70 ++++++ .../Database+Conversations.swift | 24 ++ FlipcashTests/Chat/UnreadDividerTests.swift | 219 ++++++++++++++++++ .../Chat/ChatItem+Differentiable.swift | 2 + .../Chat/ChatUnreadDividerCell.swift | 53 +++++ .../FlipcashUI/Chat/ChatViewController.swift | 74 +++++- .../NotificationTranscriptView.swift | 10 +- 11 files changed, 534 insertions(+), 25 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift create mode 100644 FlipcashTests/Chat/UnreadDividerTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 5f18980d7..0e251a6a5 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1317,6 +1317,20 @@ final class ConversationController { return (try? database.message(id: messageID, conversationID: conversationID)) ?? nil } + /// Where the viewer's unread messages begin, from their stored READ pointer and the messages + /// stored after it. Read once per visit, before ``markRead(conversationID:)`` advances the + /// pointer — see ``UnreadBoundary``. + func unreadBoundary(for conversationID: ConversationID) -> UnreadBoundary { + UnreadBoundary.resolve(readPointer: store.selfReadPointer(for: conversationID, selfUserID: selfUserID)) { readThrough in + (try? database.inboundMessageCount(conversationID: conversationID, after: readThrough, excludingSender: selfUserID)) ?? 0 + } + } + + /// 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:)) + } + /// The oldest confirmed id inside the newest-`limit` window — the anchor a first page-back grows /// from; nil when nothing is persisted. func oldestWindowedMessageID(for conversationID: ConversationID, limit: Int) -> UInt64? { diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 85e37fe13..4ab858549 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -76,7 +76,10 @@ extension ChatItem { /// so the mapper stays pure; the screen supplies classification and nothing else, and it /// defaults to no card. Takes the detected links rather than the text so the detector runs /// once per message, here. - linkCard: ([DetectedLink]) -> LinkCard? = { _ in nil } + linkCard: ([DetectedLink]) -> LinkCard? = { _ in nil }, + /// Where the viewer's unread messages began at open; the divider heads the first message + /// after it that someone else sent. + unreadBoundary: UnreadBoundary = .none ) -> [ChatItem] { // Tombstoned (deleted) messages are retained in the store for gapless ordering. Under // `.hidden` they are dropped up front so they never skew a date separator, group an adjacent @@ -103,6 +106,10 @@ extension ChatItem { || !calendar.isDate(message.date, inSameDayAs: earlier.date) } + // The unread divider heads this message. Like a separator it ends the run above it: a + // bubble must not join one across the band. + let dividedStableID = unreadBoundary.messageUnderDivider(in: messages, selfUserID: selfUserID)?.stableID + // What breaks the bubble run: a text body of one to three emoji, on a row that is not a // reply — the quote panel lives inside the bubble and has no standalone layout. Narrower // than `ChatMessage.rendersAsLargeEmoji`, which also has a link row to rule out. @@ -145,6 +152,11 @@ extension ChatItem { if showsSeparator { items.append(.dateSeparator(id: "sep-\(message.stableID)", text: message.date.formattedChatSeparator())) } + // Below the date when both fall at one gap: the reader sees the day, then what is new. + let showsDivider = message.stableID == dividedStableID + if showsDivider, let count = unreadBoundary.count { + items.append(.unreadDivider(count: count)) + } // Grouped by author, not by side: in a group chat two different people's messages sit on // the same edge, and comparing sides would merge them into one run with its facing @@ -156,10 +168,10 @@ extension ChatItem { // separator is already the heading of what follows it, so a second, shorter threshold // would flatten runs the transcript still draws as continuous. let groupedAbove = previous.map { - $0.senderID == message.senderID && !showsSeparator + $0.senderID == message.senderID && !showsSeparator && !showsDivider } ?? false let groupedBelow = next.map { - $0.senderID == message.senderID && !separates($0, from: message) + $0.senderID == message.senderID && !separates($0, from: message) && $0.stableID != dividedStableID } ?? false // The bubble run, which is not the author run. A bubble stacked above a bare emoji or a diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 7278ef2ea..c3b261ba2 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -50,6 +50,16 @@ final class ConversationLoadCoordinator { /// Names senders the chat's own roster leaves out, from what the device already knows about /// them. Observed like every other input, so a reload re-attributes the window in place. private let knownAuthors: KnownAuthorDirectory + /// Where the viewer's unread messages began at open — see ``UnreadBoundary``. Resolved in + /// `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 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? + /// Latched on the first send of the visit, so deleting that message doesn't bring the divider + /// back. + @ObservationIgnored private var viewerHasSent = false @ObservationIgnored private var lastInputs: Inputs? @ObservationIgnored private var mapTask: Task? @@ -68,6 +78,10 @@ final class ConversationLoadCoordinator { /// most one extra remap per hour on a transcript left open that long. private static let expiryHorizon: TimeInterval = 3600 + /// The most unread messages the opening window grows to include, so a chat left for weeks does + /// not lay out its whole backlog on open. + private static let unreadRevealLimit = 200 + init( conversationID: ConversationID, controller: ConversationController, @@ -81,10 +95,21 @@ final class ConversationLoadCoordinator { self.knownAuthors = knownAuthors self.profileCard = profileCard self.loader = MessageLoader(conversationID: conversationID, controller: controller) + let boundary = controller.unreadBoundary(for: conversationID) + self.openingUnreadBoundary = boundary + self.newestAtOpen = controller.lastConfirmedMessage(for: conversationID)?.id self.claimReplies = CashLinkClaimReplies(claims: session.cashLinkClaims) { [controller] messageID in Task { await controller.send(CashLinkClaimReplies.thanks, to: conversationID, repliedTo: messageID) } } + // The divider sits under the newest message at or below the pointer, so that message has to + // be in the window for the divider to draw. Past the limit the transcript opens at the bottom + // as it always has, and the divider draws once the reader pages back to it. + if case .at(let readThrough, let count) = boundary, count <= Self.unreadRevealLimit, + let anchor = controller.newestPersistedMessageID(through: readThrough, in: conversationID) { + loader.reveal(anchor) + } + // First paint is synchronous so an open never flashes an empty transcript; every later // change maps off the main thread. paint(currentInputs()) @@ -232,10 +257,29 @@ final class ConversationLoadCoordinator { // Only a transcript that attributes its rows has anything to resolve, so a DM never // takes a dependency on the directory and never re-maps when it reloads. knownAuthors: namesAuthors ? knownAuthors.snapshot : .empty, - headsHistory: headsHistory + headsHistory: headsHistory, + unreadBoundary: unreadBoundary(in: window) ) } + /// The opening boundary, or `.none` once the viewer has sent a message on this visit and the + /// lifetime ends the divider there. + private func unreadBoundary(in window: [ConversationMessage]) -> UnreadBoundary { + switch UnreadDividerLifetime.current { + case .untilClose: + return openingUnreadBoundary + case .untilSend: + if !viewerHasSent { + viewerHasSent = window.contains { message in + guard message.isFromSelf(controller.selfUserID) else { return false } + guard message.status == .sent else { return true } + return newestAtOpen.map { message.id > $0 } ?? true + } + } + return viewerHasSent ? .none : openingUnreadBoundary + } + } + nonisolated private static func map(_ inputs: Inputs, authors: [UserID: ChatAuthor]) -> [ChatItem] { let classifier = LinkCardClassifier() var items = ChatItem.from( @@ -272,7 +316,8 @@ final class ConversationLoadCoordinator { // Classification is pure and host-gated, and that is all mapping does with a link: the // card is the link's identity, and the card view looks it up for itself. So nothing // here touches the network, and an answer landing cannot re-diff this window. - linkCard: { links in classifier.firstCard(in: links) } + linkCard: { links in classifier.firstCard(in: links) }, + unreadBoundary: inputs.unreadBoundary ) if inputs.isTyping { items.append(.typingIndicator) @@ -366,6 +411,8 @@ final class ConversationLoadCoordinator { var knownAuthors: KnownAuthorDirectory.Snapshot /// Whether the window is the whole locally-known history — see ``headsHistory``. var headsHistory: Bool + /// Where the divider goes; `.none` draws none. + var unreadBoundary: UnreadBoundary struct Branding: Equatable, Sendable { var token: String diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index e60419ec3..cc5fe6e34 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -7,14 +7,17 @@ import Foundation -/// A single rendered row in the transcript: a message bubble or a centered date header. A -/// separator carries no sender or grouping, so it's a distinct case rather than a message with -/// unused fields. The delivery line rides on its message (`ChatMessage.receipt`), not as its own -/// row. The whole transcript is driven by `[ChatItem]`. +/// A single rendered row in the transcript: a message bubble, or a heading such as a centered date +/// header or the unread divider. A heading carries no sender or grouping, so it's a distinct case +/// rather than a message with unused fields. The delivery line rides on its message +/// (`ChatMessage.receipt`), not as its own row. The whole transcript is driven by `[ChatItem]`. public enum ChatItem: Hashable, Sendable, Codable, Identifiable { case message(ChatMessage) /// A centered day + time header, e.g. "Today 12:13 PM". `text` is already formatted. case dateSeparator(id: String, text: String) + /// The "N Unread Messages" band above the first message the viewer had not read when they + /// opened the chat. At most one per transcript. + case unreadDivider(count: Int) /// A leading-aligned typing bubble pinned at the transcript tail while the counterpart types. case typingIndicator /// The counterpart's profile card at the head of a short transcript. @@ -27,6 +30,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { switch self { case .message(let message): message.id case .dateSeparator(let id, _): id + case .unreadDivider: "unread-divider" case .typingIndicator: "typing-indicator" case .profileCard: "profile-card" case .groupCard: "group-card" @@ -38,7 +42,17 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { public var messageID: String? { switch self { case .message(let message): message.messageID - case .dateSeparator, .typingIndicator, .profileCard, .groupCard: nil + case .dateSeparator, .unreadDivider, .typingIndicator, .profileCard, .groupCard: nil + } + } + + /// The unread divider's label: "1 Unread Message", "12 Unread Messages", and "99+ Unread + /// Messages" past 99, as Android shows it. + public static func unreadDividerText(count: Int) -> String { + switch count { + case 1: "1 Unread Message" + case 100...: "99+ Unread Messages" + default: "\(count) Unread Messages" } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift new file mode 100644 index 000000000..7640d1d55 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift @@ -0,0 +1,70 @@ +// +// UnreadBoundary.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Where the viewer's unread messages began when they opened a chat, and how many there were. +/// +/// Resolved once per visit, before anything advances the viewer's READ pointer, and never re-read: +/// a message arriving later neither moves the divider nor changes its count. Android implements the +/// same rule (`UnreadBoundary` in the messenger module); both platforms encode one table of cases. +public enum UnreadBoundary: Hashable, Sendable { + /// No divider: nothing inbound is unread, or the viewer's own READ pointer is unknown. + case none + /// The viewer had read through `readThrough`, and `count` inbound messages followed it. + case at(readThrough: MessageID, count: Int) + + /// The boundary for a viewer whose stored READ pointer is `readPointer`, with `unreadCount` + /// answering how many inbound, non-deleted messages are stored after a given id. + /// + /// A missing pointer resolves to `.none`, not to "everything is unread": a group's roster is + /// paged, so the viewer's own member row can be absent, and treating that as a pointer of zero + /// would head the whole transcript with the divider. This is deliberately not + /// ``Conversation/hasUnread(for:)``, which counts a missing pointer as unread. + public static func resolve(readPointer: MessageID?, unreadCount: (MessageID) -> Int) -> UnreadBoundary { + guard let readPointer else { return .none } + let count = unreadCount(readPointer) + guard count > 0 else { return .none } + return .at(readThrough: readPointer, count: count) + } + + /// The message the divider heads in `messages`, oldest first: the first message after the + /// read-through that someone else sent. + /// + /// Compares ranges rather than matching the read-through message's id, so the divider still + /// lands when that message was deleted. Skips the viewer's own messages after the pointer, which + /// arise only when a send's pointer update was lost. Nil when `messages` starts after the + /// read-through, because the message the divider belongs above may not be loaded. + public func messageUnderDivider(in messages: [ConversationMessage], selfUserID: UserID) -> ConversationMessage? { + switch self { + case .none: + return nil + case .at(let readThrough, _): + guard let oldest = messages.first, oldest.id <= readThrough else { return nil } + return messages.first { $0.id > readThrough && !$0.isFromSelf(selfUserID) } + } + } + + /// The number the divider shows, or nil when there is no divider. + public var count: Int? { + switch self { + case .none: nil + case .at(_, let count): count + } + } +} + +/// How long the unread divider stays once the chat is open. Android carries the same seam. +public enum UnreadDividerLifetime: Sendable { + /// For the whole visit. + case untilClose + /// Until the viewer sends a message, which says they have caught up. + case untilSend + + /// The lifetime the app ships. Switching behaviour is a change to this one constant. + public static let current: UnreadDividerLifetime = .untilClose +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 1966bf492..bd8450ea0 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -225,6 +225,30 @@ nonisolated extension Database { return try reader.scalar(m.table.filter(m.conversationId == conversationID.data).count) } + /// The number of stored messages after `after` that someone other than `selfUserID` sent and + /// that are not deleted — the count the unread divider shows. A row with no sender counts as + /// someone else's. Index-backed by the composite `(conversationId, id)` primary key. + public func inboundMessageCount(conversationID: ConversationID, after: MessageID, excludingSender selfUserID: UUID) throws -> Int { + let m = ConversationMessageTable() + return try reader.scalar( + m.table.filter( + m.conversationId == conversationID.data + && m.id > after.value + && m.kind != 2 + && (m.senderId == nil || m.senderId != selfUserID) + ).count + ) + } + + /// The newest stored message id at or below `through`, or nil when none is stored — the row the + /// unread divider sits under when the READ pointer's own message is gone. + public func newestMessageID(conversationID: ConversationID, through: MessageID) throws -> UInt64? { + let m = ConversationMessageTable() + return try reader.pluck( + m.table.filter(m.conversationId == conversationID.data && m.id <= through.value).order(m.id.desc) + ).map { $0[m.id] } + } + /// The oldest persisted message id for a conversation, or nil when none is cached — the anchor for /// paging genuinely older history from the server. public func oldestMessageID(conversationID: ConversationID) throws -> MessageID? { diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift new file mode 100644 index 000000000..a85e6938d --- /dev/null +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -0,0 +1,219 @@ +// +// UnreadDividerTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("Unread divider") +struct UnreadDividerTests { + + private static let me = UUID() + private static let them = UUID() + + /// One row of the shared-cases table in the design spec. Android encodes the same table. + struct SharedCase: CustomTestStringConvertible, Sendable { + let name: String + /// (id, from self, deleted) — ids absent here were never stored. + let messages: [(id: UInt64, fromSelf: Bool, deleted: Bool)] + /// The viewer's READ pointer; nil stores no self member row at all. + let pointer: UInt64? + let dividerAbove: UInt64? + let count: Int + + var testDescription: String { name } + } + + nonisolated static let sharedCases: [SharedCase] = [ + SharedCase( + name: "Plain", + messages: [(1, false, false), (2, true, false), (3, false, false), (4, false, false)], + pointer: 2, dividerAbove: 3, count: 2 + ), + SharedCase( + name: "Nothing unread", + messages: [(1, false, false), (2, false, false)], + pointer: 2, dividerAbove: nil, count: 0 + ), + SharedCase( + name: "Only own after pointer", + messages: [(1, false, false), (2, true, false), (3, true, false)], + pointer: 1, dividerAbove: nil, count: 0 + ), + SharedCase( + name: "Read-through deleted", + messages: [(1, false, false), (3, false, false)], + pointer: 2, dividerAbove: 3, count: 1 + ), + SharedCase( + name: "Unread tombstone", + messages: [(1, false, false), (2, false, true), (3, false, false)], + pointer: 1, dividerAbove: 2, count: 1 + ), + SharedCase( + name: "No self row", + messages: [(1, false, false), (2, false, false)], + pointer: nil, dividerAbove: nil, count: 0 + ), + SharedCase( + name: "Own message first after pointer", + messages: [(1, false, false), (2, true, false), (3, false, false)], + pointer: 1, dividerAbove: 3, count: 1 + ), + ] + + 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 + ) + } + + /// 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 + } + + /// The id of the message row directly under the divider, or nil when there is no divider. + private func messageUnderDivider(_ items: [ChatItem]) -> String? { + guard let index = items.firstIndex(where: { if case .unreadDivider = $0 { true } else { false } }), + items.indices.contains(index + 1), + case .message(let row) = items[index + 1] else { return nil } + return row.messageID + } + + private func dividerCount(_ items: [ChatItem]) -> Int { + items.filter { if case .unreadDivider = $0 { true } else { false } }.count + } + + // MARK: - Shared cases - + + @Test("the shared cases place the divider and count as Android does", arguments: sharedCases) + func sharedCases_placeDividerAndCount(_ shared: SharedCase) async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let messages = shared.messages.map { message($0.id, fromSelf: $0.fromSelf, deleted: $0.deleted) } + let controller = try await hydratedController(messages: messages, pointer: shared.pointer, database: database) + + let boundary = controller.unreadBoundary(for: .test(1)) + #expect((boundary.count ?? 0) == shared.count) + + let items = ChatItem.from( + messages, + selfUserID: Self.me, + deletedPresentation: .placeholder, + unreadBoundary: boundary + ) + #expect(messageUnderDivider(items) == shared.dividerAbove.map { "\($0)" }) + #expect(dividerCount(items) == (shared.dividerAbove == nil ? 0 : 1)) + } + + // MARK: - Resolution - + + @Test("a missing pointer resolves to none without asking for a count") + func missingPointer_isNone() { + var asked = false + let boundary = UnreadBoundary.resolve(readPointer: nil) { _ in + asked = true + return 5 + } + #expect(boundary == .none) + #expect(!asked) + } + + @Test("a boundary resolved before markRead survives the pointer advancing") + func resolvedBeforeMarkRead_isStable() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let messages = [message(1, fromSelf: false), message(2, fromSelf: false), message(3, fromSelf: false)] + let controller = try await hydratedController(messages: messages, pointer: 1, database: database) + + let boundary = controller.unreadBoundary(for: .test(1)) + await controller.markRead(conversationID: .test(1)) + + #expect(boundary == .at(readThrough: MessageID(value: 1), count: 2)) + #expect(controller.unreadBoundary(for: .test(1)) == .none) + } + + // MARK: - Mapping - + + @Test("a date separator at the same gap is drawn above the divider") + func dateSeparatorAtBoundary_drawsAboveDivider() { + let messages = [ + message(1, fromSelf: false, after: 0), + message(2, fromSelf: false, after: 24 * 60 * 60), + ] + let items = ChatItem.from( + messages, + selfUserID: Self.me, + unreadBoundary: .at(readThrough: MessageID(value: 1), count: 1) + ) + guard let divider = items.firstIndex(where: { if case .unreadDivider = $0 { true } else { false } }) else { + Issue.record("no divider") + return + } + guard case .dateSeparator = items[divider - 1] else { + Issue.record("the row above the divider is not the date separator") + return + } + #expect(messageUnderDivider(items) == "2") + } + + @Test("the divider breaks a same-sender bubble run") + func divider_breaksGrouping() { + let messages = [message(1, fromSelf: false), message(2, fromSelf: false)] + let items = ChatItem.from( + messages, + selfUserID: Self.me, + unreadBoundary: .at(readThrough: MessageID(value: 1), count: 1) + ) + let rows = items.compactMap { if case .message(let row) = $0 { row } else { nil } } + #expect(rows.map(\.joinsBubbleBelow) == [false, false]) + } + + @Test("the label is plural, singular at one, and caps at 99+", arguments: [ + (1, "1 Unread Message"), + (2, "2 Unread Messages"), + (99, "99 Unread Messages"), + (100, "99+ Unread Messages"), + ]) + func label_formatsCount(_ count: Int, _ expected: String) { + #expect(ChatItem.unreadDividerText(count: count) == expected) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index 7fa096a30..8aa3afd71 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -33,6 +33,8 @@ extension ChatItem { ChatGroupCardCell.reuseIdentifier case .dateSeparator: ChatDateSeparatorCell.reuseIdentifier + case .unreadDivider: + ChatUnreadDividerCell.reuseIdentifier case .message(let message): switch message.content { case .text: diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift new file mode 100644 index 000000000..b73daf01f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift @@ -0,0 +1,53 @@ +// +// ChatUnreadDividerCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import SwiftUI +import UIKit +import FlipcashCore + +/// The full-width "N Unread Messages" band above the first message the viewer had not read. +public final class ChatUnreadDividerCell: UICollectionViewCell { + + public static let reuseIdentifier = "ChatUnreadDividerCell" + + private let band = UIView() + private let label = UILabel() + + public override init(frame: CGRect) { + super.init(frame: frame) + // Android's `divider` token, white at 10%. + band.backgroundColor = UIColor.white.withAlphaComponent(0.1) + band.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(band) + + label.font = .default(size: 12, weight: .bold) + label.textColor = UIColor(Color.textSecondary) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + band.addSubview(label) + + NSLayoutConstraint.activate([ + band.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + band.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4), + band.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + band.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + label.topAnchor.constraint(equalTo: band.topAnchor, constant: 8), + label.bottomAnchor.constraint(equalTo: band.bottomAnchor, constant: -8), + label.leadingAnchor.constraint(equalTo: band.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: band.trailingAnchor, constant: -16), + ]) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public func configure(count: Int) { + label.text = ChatItem.unreadDividerText(count: count) + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 4af3c5ac0..f7db66957 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -123,6 +123,10 @@ public final class ChatViewController: UICollectionViewController { /// 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 + /// Set once the open has decided between the bottom and the unread divider. A later re-armed + /// open (a non-animated update) goes to the bottom as before, rather than back to the divider + /// the reader may already have scrolled past. + private var hasPlacedUnreadDivider = false /// A row asked for before it was in `items` — the loader's window has to move first. The next /// update carrying it performs the scroll. @@ -230,6 +234,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatLinkMessageCell.self, forCellWithReuseIdentifier: ChatLinkMessageCell.reuseIdentifier) collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) collectionView.register(ChatDateSeparatorCell.self, forCellWithReuseIdentifier: ChatDateSeparatorCell.reuseIdentifier) + collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) collectionView.register(ChatTypingIndicatorCell.self, forCellWithReuseIdentifier: ChatTypingIndicatorCell.reuseIdentifier) collectionView.register(ChatProfileCardCell.self, forCellWithReuseIdentifier: ChatProfileCardCell.reuseIdentifier) collectionView.register(ChatGroupCardCell.self, forCellWithReuseIdentifier: ChatGroupCardCell.reuseIdentifier) @@ -400,6 +405,8 @@ public final class ChatViewController: UICollectionViewController { (cell as! ChatGroupCardCell).configure(with: card, onTap: cardTap, onInvite: invite) case .dateSeparator(_, let text): (cell as! ChatDateSeparatorCell).configure(text: text) + case .unreadDivider(let count): + (cell as! ChatUnreadDividerCell).configure(count: count) case .message(let message): configure(cell, with: message) } @@ -490,10 +497,36 @@ public final class ChatViewController: UICollectionViewController { /// Open at the newest message once there is content and real bounds. Runs once — ChatLayout /// keeps it anchored afterwards. + /// + /// With an unread divider, the first open lands with the divider at the top of the visible area + /// instead, unless every unread message fits on screen from the bottom. private func performInitialScrollIfNeeded() { guard needsInitialScroll, !items.isEmpty, collectionView.bounds.height > 0 else { return } needsInitialScroll = false scrollToBottom(animated: false) + guard !hasPlacedUnreadDivider, + let divider = items.firstIndex(where: { if case .unreadDivider = $0 { true } else { false } }) + else { return } + hasPlacedUnreadDivider = true + let indexPath = IndexPath(item: divider, section: 0) + if isAboveVisibleArea(indexPath) { + scrollToRowTop(indexPath) + return + } + // The rows under the divider may not have self-sized yet, so ask again once they have — + // queued behind `scrollToBottom`'s own re-anchor, and only if nothing has claimed the + // position since. + let claim = positionClaim + DispatchQueue.main.async { [weak self] in + guard let self, positionClaim == claim, isAboveVisibleArea(indexPath) else { return } + scrollToRowTop(indexPath) + } + } + + /// Whether the row at `indexPath` starts above the top of the visible area. + private func isAboveVisibleArea(_ indexPath: IndexPath) -> Bool { + guard let frame = chatLayout.layoutAttributesForItem(at: indexPath)?.frame else { return false } + return frame.minY < chatLayout.visibleBounds.minY } /// Scrolls the given row into view, or waits for the update that brings it in. @@ -540,6 +573,23 @@ public final class ChatViewController: UICollectionViewController { flashAttention(forStableID: id) } + /// Puts the top of the row at `indexPath` at the top of the visible area, without animating. The + /// top-aligned counterpart to ``scrollToRow(id:animated:)``'s centering, for a heading the reader + /// should read down from. + private func scrollToRowTop(_ indexPath: IndexPath) { + positionClaim += 1 + let claim = positionClaim + // The reader is off the bottom now, so the keyboard must not pull them back down to it. + wasAtBottom = false + let snapshot = ChatLayoutPositionSnapshot(indexPath: indexPath, edge: .top) + chatLayout.restoreContentOffset(with: snapshot) + // Re-anchor once the rows above have self-sized, as `scrollToBottom` does for the bottom. + DispatchQueue.main.async { [weak self] in + guard let self, positionClaim == claim else { return } + chatLayout.restoreContentOffset(with: snapshot) + } + } + /// Flashes the row with `stableID`, and holds it as the attention target for as long as the /// flash runs so `willDisplay` can re-attach it. /// @@ -729,7 +779,7 @@ extension ChatViewController: ChatLayoutDelegate { // Checked before the senders, as Android does: a separator is the heading for the run under // it, so it takes the same air on both sides whatever it happens to separate. - guard !isDateSeparator(at: indexPath), !isDateSeparator(at: below) else { return nil } + guard !isHeading(at: indexPath), !isHeading(at: below) else { return nil } // A pairing with no sender on one side is the profile card, which is not a bubble and keeps // the base spacing. @@ -756,17 +806,21 @@ extension ChatViewController: ChatLayoutDelegate { return message } - /// Whether the row at `indexPath` is a day header. Bounds-checked; out of range is not one. - private func isDateSeparator(at indexPath: IndexPath) -> Bool { - guard items.indices.contains(indexPath.item), - case .dateSeparator = items[indexPath.item] else { return false } - return true + /// Whether the row at `indexPath` is a day header or the unread divider. Bounds-checked; out of + /// range is not one. + private func isHeading(at indexPath: IndexPath) -> Bool { + guard items.indices.contains(indexPath.item) else { return false } + switch items[indexPath.item] { + case .dateSeparator, .unreadDivider: return true + case .message, .typingIndicator, .profileCard, .groupCard: return false + } } /// Which side of the thread the row at `indexPath` belongs to, or nil for a row that belongs to - /// neither (a date separator, the profile or group card). The typing indicator counts as the counterpart: - /// it is an incoming bubble in everything but content, so it should arrive like one and should - /// not read as a change of speaker when it follows their message. + /// neither (a date separator, the unread divider, the profile or group card). The typing + /// indicator counts as the counterpart: it is an incoming bubble in everything but content, so it + /// should arrive like one and should not read as a change of speaker when it follows their + /// message. /// /// Bounds-checked, because the layout can ask mid-batch-update, where an index path may outrun /// `items`. @@ -775,7 +829,7 @@ extension ChatViewController: ChatLayoutDelegate { switch items[indexPath.item] { case .message(let message): return message.sender case .typingIndicator: return .other - case .dateSeparator, .profileCard, .groupCard: return nil + case .dateSeparator, .unreadDivider, .profileCard, .groupCard: return nil } } } diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index 06d22bd53..c806499bd 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -35,11 +35,11 @@ struct NotificationTranscriptView: View { case .dateSeparator(_, let text): NotificationDateSeparator(text: text) .transition(.opacity) - case .typingIndicator, .profileCard, .groupCard: - // Typing is live, ephemeral state and the profile card is - // in-app chrome — neither belongs in a static notification - // snapshot and the preview mapping never emits them. Handled - // only for exhaustiveness. + case .typingIndicator, .profileCard, .groupCard, .unreadDivider: + // Typing is live, ephemeral state; the profile card and the + // unread divider are in-app chrome — none belongs in a static + // notification snapshot and the preview mapping never emits + // them. Handled only for exhaustiveness. EmptyView() } } From c4e06b9283b556feede56168e55d286141fd5eff Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 18:08:06 -0400 Subject: [PATCH 2/4] fix(chat): resolve and draw the unread divider as Android does Android (code-android-app#1546) handles the "Own message first after pointer" row at resolution rather than in the placement rule: the read-through becomes one below the first message after the pointer that someone else sent, deleted ones included. For 1:other 2:self 3:other with pointer 1 that gives readThrough 2, count 1. iOS now resolves the same way through Database.firstInboundMessageID, and the mapper goes back to the pairwise rule, so both platforms compute the same boundary rather than two rules that agree only on the table. The divider is restyled to match: a plain caption in textSecondary between two 1pt hairlines, inset to the transcript margins, instead of a filled band with bold text. When a date separator heads the divider, the opening scroll top-aligns the date instead of the divider. Aligning the divider hid the date under the navigation bar. --- .../Controllers/ConversationController.swift | 13 ++++-- .../Conversation/ChatItem+Conversation.swift | 12 ++--- .../Models/Conversation/UnreadBoundary.swift | 35 +++++++++------ .../Database+Conversations.swift | 14 ++++++ FlipcashTests/Chat/UnreadDividerTests.swift | 27 +++++++++-- .../Chat/ChatUnreadDividerCell.swift | 45 +++++++++++-------- .../FlipcashUI/Chat/ChatViewController.swift | 5 ++- 7 files changed, 106 insertions(+), 45 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 0e251a6a5..8cf299334 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1321,9 +1321,16 @@ final class ConversationController { /// stored after it. Read once per visit, before ``markRead(conversationID:)`` advances the /// pointer — see ``UnreadBoundary``. func unreadBoundary(for conversationID: ConversationID) -> UnreadBoundary { - UnreadBoundary.resolve(readPointer: store.selfReadPointer(for: conversationID, selfUserID: selfUserID)) { readThrough in - (try? database.inboundMessageCount(conversationID: conversationID, after: readThrough, excludingSender: selfUserID)) ?? 0 - } + UnreadBoundary.resolve( + readPointer: store.selfReadPointer(for: conversationID, selfUserID: selfUserID), + 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 + } + ) } /// The newest stored message id at or below `messageID`, or nil when none is stored. diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 4ab858549..889589914 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -106,9 +106,11 @@ extension ChatItem { || !calendar.isDate(message.date, inSameDayAs: earlier.date) } - // The unread divider heads this message. Like a separator it ends the run above it: a - // bubble must not join one across the band. - let dividedStableID = unreadBoundary.messageUnderDivider(in: messages, selfUserID: selfUserID)?.stableID + // The unread divider heads `message` when the read-through falls between it and `earlier`. + // Like a separator it ends the run above it: a bubble must not join one across the divider. + func divides(_ message: ConversationMessage, from earlier: ConversationMessage) -> Bool { + unreadBoundary.dividerBetween(newer: message, older: earlier, selfUserID: selfUserID) + } // What breaks the bubble run: a text body of one to three emoji, on a row that is not a // reply — the quote panel lives inside the bubble and has no standalone layout. Narrower @@ -153,7 +155,7 @@ extension ChatItem { items.append(.dateSeparator(id: "sep-\(message.stableID)", text: message.date.formattedChatSeparator())) } // Below the date when both fall at one gap: the reader sees the day, then what is new. - let showsDivider = message.stableID == dividedStableID + let showsDivider = previous.map { divides(message, from: $0) } ?? false if showsDivider, let count = unreadBoundary.count { items.append(.unreadDivider(count: count)) } @@ -171,7 +173,7 @@ extension ChatItem { $0.senderID == message.senderID && !showsSeparator && !showsDivider } ?? false let groupedBelow = next.map { - $0.senderID == message.senderID && !separates($0, from: message) && $0.stableID != dividedStableID + $0.senderID == message.senderID && !separates($0, from: message) && !divides($0, from: message) } ?? false // The bubble run, which is not the author run. A bubble stacked above a bare emoji or a diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift index 7640d1d55..bbcdbc5e7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift @@ -18,34 +18,41 @@ public enum UnreadBoundary: Hashable, Sendable { /// The viewer had read through `readThrough`, and `count` inbound messages followed it. case at(readThrough: MessageID, count: Int) - /// The boundary for a viewer whose stored READ pointer is `readPointer`, with `unreadCount` - /// answering how many inbound, non-deleted messages are stored after a given id. + /// The boundary for a viewer whose stored READ pointer is `readPointer`. + /// + /// `firstInbound` answers the oldest stored message after a given id that someone else sent, + /// deleted or not; `unreadCount` answers how many inbound, non-deleted messages are stored after + /// it. The resolved read-through sits just below that first inbound message, so the viewer's + /// own messages after the pointer fall above the divider. Those arise only when a send's pointer + /// update was lost, and the placement rule alone would find no gap for them. Android resolves it + /// the same way. /// /// A missing pointer resolves to `.none`, not to "everything is unread": a group's roster is /// paged, so the viewer's own member row can be absent, and treating that as a pointer of zero /// would head the whole transcript with the divider. This is deliberately not /// ``Conversation/hasUnread(for:)``, which counts a missing pointer as unread. - public static func resolve(readPointer: MessageID?, unreadCount: (MessageID) -> Int) -> UnreadBoundary { - guard let readPointer else { return .none } + public static func resolve( + readPointer: MessageID?, + firstInbound: (MessageID) -> MessageID?, + unreadCount: (MessageID) -> Int + ) -> UnreadBoundary { + guard let readPointer, let first = firstInbound(readPointer) else { return .none } let count = unreadCount(readPointer) guard count > 0 else { return .none } - return .at(readThrough: readPointer, count: count) + return .at(readThrough: MessageID(value: first.value - 1), count: count) } - /// The message the divider heads in `messages`, oldest first: the first message after the - /// read-through that someone else sent. + /// Whether the divider sits between two adjacent messages, `older` directly above `newer`. /// /// Compares ranges rather than matching the read-through message's id, so the divider still - /// lands when that message was deleted. Skips the viewer's own messages after the pointer, which - /// arise only when a send's pointer update was lost. Nil when `messages` starts after the - /// read-through, because the message the divider belongs above may not be loaded. - public func messageUnderDivider(in messages: [ConversationMessage], selfUserID: UserID) -> ConversationMessage? { + /// lands when that message was deleted or is outside the loaded window. Ids increase down the + /// transcript, so at most one adjacent pair answers true. + public func dividerBetween(newer: ConversationMessage, older: ConversationMessage, selfUserID: UserID) -> Bool { switch self { case .none: - return nil + return false case .at(let readThrough, _): - guard let oldest = messages.first, oldest.id <= readThrough else { return nil } - return messages.first { $0.id > readThrough && !$0.isFromSelf(selfUserID) } + return older.id <= readThrough && readThrough < newer.id && !newer.isFromSelf(selfUserID) } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index bd8450ea0..a194ab760 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -240,6 +240,20 @@ nonisolated extension Database { ) } + /// 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. + public func firstInboundMessageID(conversationID: ConversationID, after: MessageID, excludingSender selfUserID: UUID) throws -> UInt64? { + let m = ConversationMessageTable() + return try reader.pluck( + m.table.filter( + m.conversationId == conversationID.data + && m.id > after.value + && (m.senderId == nil || m.senderId != selfUserID) + ).order(m.id.asc) + ).map { $0[m.id] } + } + /// The newest stored message id at or below `through`, or nil when none is stored — the row the /// unread divider sits under when the READ pointer's own message is gone. public func newestMessageID(conversationID: ConversationID, through: MessageID) throws -> UInt64? { diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index a85e6938d..e3d05bce2 100644 --- a/FlipcashTests/Chat/UnreadDividerTests.swift +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -149,14 +149,33 @@ struct UnreadDividerTests { @Test("a missing pointer resolves to none without asking for a count") func missingPointer_isNone() { var asked = false - let boundary = UnreadBoundary.resolve(readPointer: nil) { _ in - asked = true - return 5 - } + let boundary = UnreadBoundary.resolve( + readPointer: nil, + firstInbound: { _ in + asked = true + return MessageID(value: 2) + }, + unreadCount: { _ in + asked = true + return 5 + } + ) #expect(boundary == .none) #expect(!asked) } + @Test("the read-through resolves to just below the first message someone else sent after the pointer") + func ownMessageAfterPointer_movesReadThroughPastIt() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let messages = [message(1, fromSelf: false), message(2, fromSelf: true), message(3, fromSelf: false)] + let controller = try await hydratedController(messages: messages, pointer: 1, database: database) + + // Android's resolution gives the same value for this row, which keeps the two platforms' + // placement identical. + #expect(controller.unreadBoundary(for: .test(1)) == .at(readThrough: MessageID(value: 2), count: 1)) + } + @Test("a boundary resolved before markRead survives the pointer advancing") func resolvedBeforeMarkRead_isStable() async throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift index b73daf01f..483f0a50c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift @@ -10,36 +10,37 @@ import SwiftUI import UIKit import FlipcashCore -/// The full-width "N Unread Messages" band above the first message the viewer had not read. +/// The "N Unread Messages" caption between two hairlines, above the first message the viewer had +/// not read. public final class ChatUnreadDividerCell: UICollectionViewCell { public static let reuseIdentifier = "ChatUnreadDividerCell" - private let band = UIView() private let label = UILabel() public override init(frame: CGRect) { super.init(frame: frame) - // Android's `divider` token, white at 10%. - band.backgroundColor = UIColor.white.withAlphaComponent(0.1) - band.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(band) - - label.font = .default(size: 12, weight: .bold) + label.font = .default(size: 12) label.textColor = UIColor(Color.textSecondary) label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - band.addSubview(label) + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + + let leading = Self.hairline() + let trailing = Self.hairline() + let row = UIStackView(arrangedSubviews: [leading, label, trailing]) + row.axis = .horizontal + row.alignment = .center + row.spacing = 8 + row.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(row) NSLayoutConstraint.activate([ - band.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), - band.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4), - band.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - band.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - label.topAnchor.constraint(equalTo: band.topAnchor, constant: 8), - label.bottomAnchor.constraint(equalTo: band.bottomAnchor, constant: -8), - label.leadingAnchor.constraint(equalTo: band.leadingAnchor, constant: 16), - label.trailingAnchor.constraint(equalTo: band.trailingAnchor, constant: -16), + leading.widthAnchor.constraint(equalTo: trailing.widthAnchor), + row.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + row.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + row.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + row.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), ]) } @@ -49,5 +50,13 @@ public final class ChatUnreadDividerCell: UICollectionViewCell { public func configure(count: Int) { label.text = ChatItem.unreadDividerText(count: count) } + + /// Android's `divider` token, white at 10%, 1pt tall as `HorizontalDivider` draws it. + private static func hairline() -> UIView { + let line = UIView() + line.backgroundColor = UIColor.white.withAlphaComponent(0.1) + line.heightAnchor.constraint(equalToConstant: 1 / UITraitCollection.current.displayScale).isActive = true + return line + } } #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index f7db66957..52afe1aa9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -508,7 +508,10 @@ public final class ChatViewController: UICollectionViewController { let divider = items.firstIndex(where: { if case .unreadDivider = $0 { true } else { false } }) else { return } hasPlacedUnreadDivider = true - let indexPath = IndexPath(item: divider, section: 0) + // A date on the same gap is drawn above the divider and goes up with it, so the reader + // lands on the day as well as the count. + let headsDivider = divider > 0 && { if case .dateSeparator = items[divider - 1] { true } else { false } }() + let indexPath = IndexPath(item: headsDivider ? divider - 1 : divider, section: 0) if isAboveVisibleArea(indexPath) { scrollToRowTop(indexPath) return From 8f8b1d34b99e96dd752f1c5f71aa2c1f99f04afa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 18:17:07 -0400 Subject: [PATCH 3/4] fix(chat): show no unread divider when nothing below it is stored With no stored message at or below the resolved read-through, the store has a gap under the unread run: older unread messages may exist that it never fetched, so the count could be short. Resolve to no divider there, and add the case to the shared table. --- .../Core/Controllers/ConversationController.swift | 3 +++ .../Models/Conversation/UnreadBoundary.swift | 11 +++++++++-- FlipcashTests/Chat/UnreadDividerTests.swift | 9 +++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 8cf299334..c3e45cc9f 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1329,6 +1329,9 @@ final class ConversationController { }, unreadCount: { readPointer in (try? database.inboundMessageCount(conversationID: conversationID, after: readPointer, excludingSender: selfUserID)) ?? 0 + }, + hasStored: { readThrough in + newestPersistedMessageID(through: readThrough, in: conversationID) != nil } ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift index bbcdbc5e7..904c0f922 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift @@ -31,15 +31,22 @@ public enum UnreadBoundary: Hashable, Sendable { /// paged, so the viewer's own member row can be absent, and treating that as a pointer of zero /// would head the whole transcript with the divider. This is deliberately not /// ``Conversation/hasUnread(for:)``, which counts a missing pointer as unread. + /// + /// `hasStored` answers whether any message at or below a given id is stored. When none is, the + /// store has a gap below the unread run and can't know how many unread messages precede it, so + /// the boundary resolves to `.none` rather than a count that may be short. public static func resolve( readPointer: MessageID?, firstInbound: (MessageID) -> MessageID?, - unreadCount: (MessageID) -> Int + unreadCount: (MessageID) -> Int, + hasStored: (MessageID) -> Bool ) -> UnreadBoundary { guard let readPointer, let first = firstInbound(readPointer) else { return .none } + let readThrough = MessageID(value: first.value - 1) + guard hasStored(readThrough) else { return .none } let count = unreadCount(readPointer) guard count > 0 else { return .none } - return .at(readThrough: MessageID(value: first.value - 1), count: count) + return .at(readThrough: readThrough, count: count) } /// Whether the divider sits between two adjacent messages, `older` directly above `newer`. diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index e3d05bce2..89a98638d 100644 --- a/FlipcashTests/Chat/UnreadDividerTests.swift +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -68,6 +68,11 @@ struct UnreadDividerTests { messages: [(1, false, false), (2, true, false), (3, false, false)], pointer: 1, dividerAbove: 3, count: 1 ), + SharedCase( + name: "Nothing stored at or below read-through", + messages: [(3, false, false), (4, false, false)], + pointer: 1, dividerAbove: nil, count: 0 + ), ] private let base = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 1_000_000)) @@ -158,6 +163,10 @@ struct UnreadDividerTests { unreadCount: { _ in asked = true return 5 + }, + hasStored: { _ in + asked = true + return true } ) #expect(boundary == .none) From c00aefea9717c96ae43c15cb78bbb426d5a4e4ee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:40:56 -0400 Subject: [PATCH 4/4] fix(chat): set the unread divider in the caption type token The label used Avenir Regular 12. Android's divider uses the caption token, whose Medium weight loads avenir_next_demi, so it renders in Avenir Demi 12. appTextCaption is the same face and size on iOS, and the date separator above the divider already uses it. --- FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift index 483f0a50c..75b0939db 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift @@ -20,7 +20,7 @@ public final class ChatUnreadDividerCell: UICollectionViewCell { public override init(frame: CGRect) { super.init(frame: frame) - label.font = .default(size: 12) + label.font = .appTextCaption label.textColor = UIColor(Color.textSecondary) label.textAlignment = .center label.setContentHuggingPriority(.required, for: .horizontal)