Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
20 changes: 17 additions & 3 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

Expand All @@ -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,
Expand All @@ -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())
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand All @@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
Loading
Loading