Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
550fcf9
feat(chat): add jump-to-bottom button with unread badge to the transc…
Oct 14, 2026
6fdf9ee
fix(chat): bump messageRevision after a media send so the unread coun…
Oct 14, 2026
f581d7c
fix(chat): layer the jump button under the transcript blur and over t…
Oct 14, 2026
f5517bf
fix(chat): drop the landed-open wasAtBottom recompute, which the divi…
Oct 14, 2026
1305adb
fix(chat): extend the jump button hit area over the badge overhang an…
Oct 14, 2026
76c32f2
feat(chat): advance the viewer read pointer to a confirmed own send, …
Oct 14, 2026
a088900
feat(chat): freeze the jump badge at the unread divider count until t…
Oct 14, 2026
3bdbf2d
test(chat): make the unread-count tests observe messageRevision, cove…
Oct 14, 2026
baadeca
fix(chat): re-check at-bottom when a scroll ends without a drag
bmc08gt Oct 14, 2026
82284c1
fix(chat): count only post-load arrivals on the jump badge and releas…
bmc08gt Oct 14, 2026
4c9180b
fix(chat): apply the opening-load landing to a late coordinator and r…
bmc08gt Oct 14, 2026
5423be1
fix(chat): seat the jump badge's arrival cutoff only after a successf…
bmc08gt Oct 14, 2026
87f7211
fix(chat): drop the gradient behind the jump-to-bottom button
bmc08gt Oct 14, 2026
9735a39
fix(chat): line the jump-to-bottom button up with the $ button
bmc08gt Oct 14, 2026
b42ddd7
fix(chat): move the jump-to-bottom button with the $ button
bmc08gt Oct 14, 2026
0d06003
fix(chat): keep system messages out of the badge's arrival cutoff
bmc08gt Oct 14, 2026
b2fc810
fix(chat): draw the jump-to-bottom button in SwiftUI beside the compo…
bmc08gt Oct 14, 2026
e2e894e
fix(chat): give the jump-to-bottom button the composer's glass
bmc08gt Oct 15, 2026
c82e191
fix(chat): draw the jump-to-bottom button inside the composer bar's h…
bmc08gt Oct 15, 2026
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
86 changes: 74 additions & 12 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1605,7 +1605,7 @@ final class ConversationController {
/// Persist operations that write confirmed messages — the ones that must bump `messageRevision`.
private static let messageWriteOperations: Set<String> = [
"upsert-messages", "apply-chat-events", "delta-batch", "load-messages", "load-older",
"send-message", "reset-resync", "edit-message", "delete-message",
"send-message", "send-media-message", "reset-resync", "edit-message", "delete-message",
]

// MARK: - Names
Expand Down Expand Up @@ -1797,6 +1797,10 @@ final class ConversationController {
/// re-fire through the store directly.
private(set) var messageRevision = 0

/// Own sends the server has confirmed this session, per chat. Observed by the jump badge, which
/// reads a rise as the viewer having caught up with the transcript.
private(set) var confirmedOwnSendCounts: [ConversationID: Int] = [:]

/// Forces the transcript to re-read its window. `persist(operation:)` does this for database
/// writes; an overlay change writes nothing, so it has to say so explicitly.
func bumpMessageRevision() {
Expand Down Expand Up @@ -1911,20 +1915,49 @@ final class ConversationController {
/// pointer — see ``UnreadBoundary``.
func unreadBoundary(for conversationID: ConversationID) -> UnreadBoundary {
UnreadBoundary.resolve(
readPointer: store.selfReadPointer(for: conversationID, selfUserID: selfUserID),
readPointer: readThrough(for: conversationID),
firstInbound: { readPointer in
((try? database.firstInboundMessageID(conversationID: conversationID, after: readPointer, excludingSender: selfUserID)) ?? nil)
.map(MessageID.init(value:))
},
unreadCount: { readPointer in
(try? database.inboundMessageCount(conversationID: conversationID, after: readPointer, excludingSender: selfUserID)) ?? 0
inboundMessageCount(after: readPointer, in: conversationID)
},
hasStored: { readThrough in
newestPersistedMessageID(through: readThrough, in: conversationID) != nil
}
)
}

/// The newest inbound id the viewer has seen: the stored READ pointer or the unsynced one ahead of
/// it. The one value the unread divider, the jump-to-bottom badge, and
/// ``advanceReadPointer(to:in:)`` all read, so they can't disagree.
func readThrough(for conversationID: ConversationID) -> MessageID? {
[
store.selfReadPointer(for: conversationID, selfUserID: selfUserID),
store.unsyncedSelfReadPointers[conversationID],
].compactMap { $0 }.max()
}

/// How many stored, non-deleted messages someone else sent after `messageID`.
func inboundMessageCount(after messageID: MessageID, in conversationID: ConversationID) -> Int {
(try? database.inboundMessageCount(conversationID: conversationID, after: messageID, excludingSender: selfUserID)) ?? 0
}

/// The newest message someone else sent, or nil when there is none.
func newestInboundMessageID(in conversationID: ConversationID) -> MessageID? {
((try? database.newestInboundMessageID(conversationID: conversationID, excludingSender: selfUserID)) ?? nil)
}

/// Inbound messages the viewer hasn't seen: those after ``readThrough(for:)``, or 0 when the
/// pointer is unknown (as ``unreadBoundary(for:)`` treats it). Observes the read pointer and
/// `messageRevision`, so it re-evaluates when either the pointer moves or a message lands.
func unreadBelowCount(in conversationID: ConversationID) -> Int {
_ = messageRevision // observe: re-count when a confirmed DB write lands
guard let readThrough = readThrough(for: conversationID) else { return 0 }
return inboundMessageCount(after: readThrough, in: conversationID)
}

/// The newest stored message id at or below `messageID`, or nil when none is stored.
func newestPersistedMessageID(through messageID: MessageID, in conversationID: ConversationID) -> MessageID? {
((try? database.newestMessageID(conversationID: conversationID, through: messageID)) ?? nil).map(MessageID.init(value:))
Expand Down Expand Up @@ -1959,12 +1992,15 @@ final class ConversationController {
return store.hasPendingMessages(for: conversationID) || (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 } != nil
}

func loadMessages(for conversationID: ConversationID) async {
/// Loads the newest page. Returns whether it was fetched and persisted; a skipped, failed or
/// unpersisted load returns false and records no landing.
@discardableResult
func loadMessages(for conversationID: ConversationID) async -> Bool {
guard canRead(conversationID) else {
logger.info("Skipping message load for a conversation the user may not read", metadata: [
"conversationID": "\(conversationID)",
])
return
return false
}
messageLoadsInFlight.insert(conversationID)
// This load is the backfill: a queued one would fetch the same page again.
Expand All @@ -1991,17 +2027,32 @@ final class ConversationController {
if ok {
commitReconciled(pairs, in: conversationID)
if head > 0 { store.setAppliedCursor(head, for: conversationID) }
recordNewestPageLanding(in: conversationID)
}
refreshFeedPreview(for: conversationID)
return ok
} catch {
logger.error("Failed to load conversation messages", metadata: [
"conversationID": "\(conversationID)",
"error": "\(error)",
])
ErrorReporting.captureError(error, reason: "Failed to load conversation messages")
return false
}
}

/// Successful newest-page loads per chat, from any path: the opening load, a retry, a backfill,
/// the gate lifting, a join.
@ObservationIgnored private(set) var newestPageLandings: [ConversationID: Int] = [:]
/// The newest inbound id stored when the latest landing finished, so the history that page
/// brought in can be told apart from what arrives after it.
@ObservationIgnored private(set) var newestInboundAtLanding: [ConversationID: MessageID] = [:]

private func recordNewestPageLanding(in conversationID: ConversationID) {
newestPageLandings[conversationID, default: 0] += 1
newestInboundAtLanding[conversationID] = newestInboundMessageID(in: conversationID)
}

// MARK: - Pagination

/// Per-conversation older-history paging state, observed so the transcript can
Expand Down Expand Up @@ -2165,6 +2216,12 @@ final class ConversationController {
scheduleGapCatchUp(conversationID)
}
store.advanceLastActivity(to: message.date, in: conversationID)
// The server confirmed the send, so everything up to it is seen; matches Android's
// `MessagingDelegate` advancing its own pointer to the sent id.
// The jump badge's hold releases only onto a pointer that moved.
if advanceReadPointer(to: message.id, in: conversationID) {
confirmedOwnSendCounts[conversationID, default: 0] += 1
}
refreshFeedPreview(for: conversationID)
persistConversation(conversationID)
// Outside the `ok` branch: the message reached the server either way, so a draft put
Expand Down Expand Up @@ -2494,6 +2551,10 @@ final class ConversationController {
scheduleGapCatchUp(conversationID)
}
store.advanceLastActivity(to: message.date, in: conversationID)
// The jump badge's hold releases only onto a pointer that moved.
if advanceReadPointer(to: message.id, in: conversationID) {
confirmedOwnSendCounts[conversationID, default: 0] += 1
}
refreshFeedPreview(for: conversationID)
persistConversation(conversationID)
Analytics.sentMessage(chatType: chatType)
Expand All @@ -2518,23 +2579,24 @@ final class ConversationController {
/// inbound messages it crossed report their receipts. `AdvancePointer` goes out after. When it
/// fails, the local pointer stays ahead and the next feed load sends it again. Android's
/// `MessagingDelegate.advanceReadPointer` works in the same order.
func advanceReadPointer(to messageID: MessageID, in conversationID: ConversationID) {
///
/// Returns whether the pointer now sits at or past `messageID`; false when the viewer can't move it.
@discardableResult
func advanceReadPointer(to messageID: MessageID, in conversationID: ConversationID) -> Bool {
// A pointer advance is denied to a non-member even when the fetch isn't, and a chat the user
// cannot read has nothing to mark.
guard canAdvancePointer(conversationID) else { return }
guard canAdvancePointer(conversationID) else { return false }
// A chat not in the feed yet has no member row to hold the pointer, only the unsynced entry.
let previous = [
store.selfReadPointer(for: conversationID, selfUserID: selfUserID),
store.unsyncedSelfReadPointers[conversationID],
].compactMap { $0 }.max()
if let previous, messageID <= previous { return }
let previous = readThrough(for: conversationID)
if let previous, messageID <= previous { return true }
store.advanceSelfReadPointer(to: messageID, in: conversationID, selfUserID: selfUserID)
// The pointer only moves forward, so the window it just crossed holds exactly the messages the
// reader is seeing for the first time. The reporter keeps only the inbound ones.
let crossed = (try? database.messages(conversationID: conversationID, after: previous, through: messageID)) ?? []
receipts.reportRead(crossed, chatType: conversation(withID: conversationID)?.type)
persistConversation(conversationID)
syncReadPointer(in: conversationID)
return true
}

/// Sends the viewer's unsynced READ pointer for `conversationID`, if it has one. Called as the
Expand Down
15 changes: 14 additions & 1 deletion Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
let onMessagesSeen: (MessageID) -> Void
/// Whether rows on screen count as read right now.
let reportsReads: Bool
/// Inbound messages after the viewer's read pointer, for the jump-to-bottom badge.
let unreadBelowCount: Int
/// Fired on the reader's first drag after the opening scroll.
let onUserScrollBegan: () -> Void
/// Fired when a reaction pill is tapped: the row's stable id and the toggled emoji.
let onReactionTap: (String, String) -> Void
/// Fired when a reaction pill is long-pressed: the row's stable id and emoji, to open the
Expand Down Expand Up @@ -133,6 +137,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
var transcriptTopInset: CGFloat = 0

func makeUIViewController(context: Context) -> ChatScreenViewController {
let jump = JumpToBottomModel()
context.coordinator.jump = jump
let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator))
barHost.view.backgroundColor = .clear
// The bar's content is pinned to the bottom of this view and overhangs the top while the
Expand All @@ -142,7 +148,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
// indicator's safe area. Left to respect that safe area, the bar pushes its content up by
// however far it rests into it, past the clip's top edge.
barHost.safeAreaRegions = []
let screen = ChatScreenViewController(bar: barHost.view, barController: barHost)
let screen = ChatScreenViewController(bar: barHost.view, barController: barHost, jump: jump)
screen.focusesComposerOnAppear = focusOnAppear
screen.isTranscriptObscured = gate.obscuresTranscript
screen.showsGatePlaceholder = showsGatePlaceholder
Expand Down Expand Up @@ -189,6 +195,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onCancelEdit = { [composer] in composer.endEditing() }
screen.onMentionRoomChange = { [mentions] room in mentions?.room = room }
screen.onMessagesSeen = onMessagesSeen
screen.unreadBelowCount = unreadBelowCount
screen.onUserScrollBegan = onUserScrollBegan
screen.reportsReads = reportsReads
screen.update(items: items)
context.coordinator.barHost = barHost
Expand Down Expand Up @@ -239,6 +247,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onCancelEdit = { [composer] in composer.endEditing() }
screen.onMentionRoomChange = { [mentions] room in mentions?.room = room }
screen.onMessagesSeen = onMessagesSeen
screen.unreadBelowCount = unreadBelowCount
screen.onUserScrollBegan = onUserScrollBegan
screen.reportsReads = reportsReads
// The backdrop is raised from the menu action itself (see `keyboardFollowing`) because it
// has to claim the menu's blur before the dismissal fades it; it comes down here, whichever
Expand Down Expand Up @@ -317,6 +327,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
onSendCash: onSendCash,
model: barModel,
composer: composer,
jump: coordinator.jump,
gate: gate,
gateMintName: gateMintName,
gateShortfall: gateShortfall,
Expand Down Expand Up @@ -361,6 +372,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {

@MainActor final class Coordinator {
var barHost: UIHostingController<AnyView>?
/// What the bar's jump-to-bottom button shows; handed to the screen that drives it.
var jump: JumpToBottomModel?
weak var screen: ChatScreenViewController?
let model: ConversationBarModel
let composer: ComposerModel
Expand Down
Loading
Loading