Correction to the comment above, after reading `ChatScreen` properly. **Both "product calls" dissolve, so neither needs an answer** — the defaults I proposed there are superseded by this.
Two things in the screen that the earlier reading missed:
- `unreadLanding(entries, mark)` (`ChatScreen.kt:440`) needs the unread divider to be inside whatever is loaded. A window that is simply "the newest N" lands a chat with more unread than N in the wrong place.
- `searchMatches` (`:537`) matches the **sender's display name** as well as the message text, through `session.label(it, profiles)`. No SQL `LIKE` over `content` can reproduce that, so "the find bar searches the store" was not a default I could have implemented as stated.
What replaces both: **the window's floor drops to the bottom of the chat whenever something deliberate asks for it.** Opening the find bar drops it to zero for as long as the bar is open. A permalink, a reply's jump or a pin's jump to a row below the floor drops it, then jumps. That is today's behaviour exactly, at today's cost, paid only when a person asks for it — and the read the issue is actually about, the one that runs on every arriving message and every chat open, is the one that shrinks. No new search path, no new wording, and nothing for anyone to decide on either count.
The rest of the shape:
- `AccountSession.timeline(gid)` takes a floor. The flow reads `cursor >= floor`, and the `Timeline` carries `hasMore` — which is `Message.sq:countBelow`, already written for the prune.
- The opening floor is the **lower** of the Nth-newest cursor (`selectNthNewestCursor`, also already there and also callerless) and one above the read mark, so every unread row is loaded and the landing still finds its divider. An account with four hundred unread in a chat pays for four hundred rows on opening it, which is the right answer for that person and still not the whole history.
- Scrolling within a page of the top lowers the floor by a page.
- The top of the list gains its third state, which remains the defect most likely to come out of this: "more to load" while `hasMore`; `PrunedHistoryNote` when `hasMore` is false and `group.pruned`; nothing otherwise. Never inferred from `rows.size`.
- A reply's quoted line and the pin banner need the target's *text*, not its position in the list, so they take a store lookup by event id (`messageByEventId`, indexed) and leave the floor where it is. Only an actual jump moves it.
Three of the reads this needs are already in `Message.sq` and two of them have never had a caller.