diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 5f18980d7..c3e45cc9f 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1317,6 +1317,30 @@ 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), + 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 + }, + hasStored: { readThrough in + newestPersistedMessageID(through: readThrough, in: conversationID) != nil + } + ) + } + + /// 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..889589914 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,12 @@ extension ChatItem { || !calendar.isDate(message.date, inSameDayAs: earlier.date) } + // 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 // than `ChatMessage.rendersAsLargeEmoji`, which also has a link row to rule out. @@ -145,6 +154,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 = previous.map { divides(message, from: $0) } ?? false + 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 +170,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) && !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/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..904c0f922 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift @@ -0,0 +1,84 @@ +// +// 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`. + /// + /// `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. + /// + /// `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, + 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: readThrough, count: count) + } + + /// 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 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 false + case .at(let readThrough, _): + return older.id <= readThrough && readThrough < newer.id && !newer.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..a194ab760 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -225,6 +225,44 @@ 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 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? { + 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..89a98638d --- /dev/null +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -0,0 +1,247 @@ +// +// 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 + ), + 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)) + .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, + firstInbound: { _ in + asked = true + return MessageID(value: 2) + }, + unreadCount: { _ in + asked = true + return 5 + }, + hasStored: { _ in + asked = true + return true + } + ) + #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() + 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..75b0939db --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatUnreadDividerCell.swift @@ -0,0 +1,62 @@ +// +// ChatUnreadDividerCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import SwiftUI +import UIKit +import FlipcashCore + +/// 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 label = UILabel() + + public override init(frame: CGRect) { + super.init(frame: frame) + label.font = .appTextCaption + label.textColor = UIColor(Color.textSecondary) + label.textAlignment = .center + 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([ + 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), + ]) + } + + @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) + } + + /// 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 4af3c5ac0..52afe1aa9 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,39 @@ 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 + // 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 + } + // 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 +576,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 +782,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 +809,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 +832,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() } }