Skip to content

feat(chat): mark where unread messages start with a divider - #845

Merged
bmc08gt merged 4 commits into
mainfrom
feat/chat-unread-divider
Sep 24, 2026
Merged

bmc08gt merged 4 commits into
mainfrom
feat/chat-unread-divider

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Opening a chat now draws an "N Unread Messages" band above the first message someone else sent after the viewer's READ pointer. This is the iOS half of the shared rule in the Android design spec (2026-09-23-chat-unread-divider-design.md).

Boundary. ConversationLoadCoordinator.init reads the boundary from ConversationStore.selfReadPointer. The screen builds the coordinator in onAppear, before the opening .task reaches markRead, so the boundary reflects the pointer at open. It is never read again on that visit. With no stored self member row there is no divider. A group roster is paged, so the row can be missing, and hasUnread's treatment of a nil pointer as unread would head the whole transcript. The count comes from Database.inboundMessageCount: stored rows past the pointer that are not the viewer's and not tombstones.

Placement. Resolution sets the read-through to one below the first stored message after the pointer that someone else sent, deleted ones included. The mapper then draws the divider between the adjacent pair where older.id <= readThrough < newer.id and newer isn't from the viewer. Comparing ranges means a deleted read-through message doesn't matter. This is Android's resolution (code-payments/code-android-app#1546). It is what makes the table's "Own message first after pointer" row work: 1:other 2:self 3:other with pointer 1 resolves to readThrough 2, count 1, and the pairwise rule alone finds no gap for it. When a date separator falls at the same gap, the date is drawn above the divider. The divider is a plain caption between two hairlines: "1 Unread Message" or "N Unread Messages", and "99+" past 99.

Scroll. When the divider starts above the visible area after the bottom scroll, ChatViewController restores a .top ChatLayout snapshot on it, or on the date separator above it when there is one, so it lands just under the nav bar. When it is already visible, the chat stays at the bottom. The loader grows its window back to the read-through message when there are at most 200 unread. Past that, the chat opens at the bottom and the divider shows once the reader pages back to it.

Lifetime. UnreadDividerLifetime.current is .untilClose. Changing it to .untilSend clears the divider on the viewer's first send of the visit.

UnreadDividerTests encodes the seven shared cases, run through the database, the stored member row, the controller's resolver and the mapper.

Read tracking is unchanged: the chat is still marked read in full on open.

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.
@bmc08gt bmc08gt self-assigned this Sep 23, 2026
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.
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.
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.
@bmc08gt
bmc08gt merged commit d914bd8 into main Sep 24, 2026
1 check passed
bmc08gt added a commit that referenced this pull request Sep 24, 2026
…-avatars

* origin/main:
  feat(chat): mark where unread messages start with a divider (#845)
  feat(chat): thank the sender when a cash link from chat is collected (#843)
  fix(notifications): treat a lost WAL lock race as contention in the extension store (#842)

# Conflicts:
#	FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift
@bmc08gt
bmc08gt deleted the feat/chat-unread-divider branch October 5, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant