The home screen listed rooms by name and nothing else, in whatever order SQLite
handed them back -- which for a query with no ORDER BY is rowid, so the list was
ordered by when each room was first written and never moved again. The room
somebody messaged an hour ago sat wherever it was created, indistinguishable
from one nobody has touched since March.
**The query.** `ChatRoomDao`'s room reads now LEFT JOIN each room's newest
`ChatMessage` and order on it. The join is a correlated subquery rather than a
`GROUP BY chatRoomId` with `MAX(createdAt)`:
ON lastMessage.id = (SELECT id FROM ChatMessage
WHERE chatRoomId = ChatRoom.id AND deletedAt IS NULL
ORDER BY createdAt DESC, id DESC LIMIT 1)
`MantraConverters` stores an `Instant` as epoch *seconds*, so lines written in
one second tie -- a ceremony puts a dozen into a room faster than that -- and
the aggregate form resolves a tie arbitrarily, which would leave a room quoting
whichever of its last three lines SQLite happened to reach first. `id DESC`
breaks it on write order, which is the order the transcript shows them in, so
the list and the room it opens agree about what was said last.
A room with nothing said in it sorts on its own `createdAt`. The alternative is
sorting it last, which buries a room the user just made under every conversation
they have ever had.
**The flow now re-emits on message traffic**, because the query reads ChatMessage
and Room invalidates on the tables a query touches. That is the point -- a row's
preview and its place in the order stay current without the list asking for
either -- but it is a real change for the other collector of this flow.
`LiveSubscriptionManager.followGroupMembership` maps to group ids through
`distinctUntilChanged()` before its debounce, so the extra emissions collapse
there and no relay subscription churns on an arriving message.
**The carried line is a `ChatRoomLastMessage`, not a `ChatMessage`.** Embedding
the entity would mean aliasing thirty-odd columns onto every room query, and
colliding with the room's own `id` and all four of its timestamps on the way.
Six columns are everything a one-line preview and a clock can be written from.
It is nullable, and every other way of getting a `LocalChatRoom` leaves it null
rather than paying for a join no screen reads. So a null there means "not asked
for" as often as it means "nothing said", which is why nothing hangs a decision
on it beyond what to draw.
**What that line is rendered as** follows the transcript's own dispatch in
`ChatMessageListViewModel`, because the two must not disagree about what a room's
newest activity was:
- a ritual or chronicle line is nobody's words. Its content is written as a
predicate for an actor's name, so the authored ones get that name in front
("Alice published their share") and the rest stand alone ("The group now has a
shared key"). A name in front of the latter reads as that member having
announced it, which is exactly the misattribution the transcript renders these
as system lines to avoid.
- a direct message with blank content is one this device cannot open. An empty
preview reads as the sender having said nothing, so the line says instead what
the group can in fact see: that a private message was sent, and to whom.
- anything else is somebody's words, prefixed with who said them -- except in a
two-person room, where the only other name is already the row's title and
repeating it says nothing.
Names resolve through the existing `HexKey.memberName` rather than a second copy
of that lookup, so they follow a rename and fall back to a shortened key instead
of dropping the attribution to nobody.
17 new tests. Seven run against a real SQLite, for the parts only it can answer
-- which row the subquery picks, the same-second tie, room scoping, a
soft-deleted last line, and where a room with no messages lands. Ten exercise
the preview text directly, one per shape above plus the unknown-sender fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>