feat: carry each room's newest line with the room, and say it in one line

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>
This commit is contained in:
Kgothatso Ngako
2026-09-06 18:21:19 +02:00
parent fd9137ab5a
commit 50feb6fa0c
5 changed files with 542 additions and 4 deletions

View File

@@ -0,0 +1,206 @@
package press.mantra.compose.database.model.intermdiate
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.Profile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.time.Instant
/**
* The one line under a room's name in the chat list.
*
* It is a summary of somebody's message shown next to their name, which is the shape
* every way of getting this wrong takes: attributing a ceremony milestone to the member
* who happened to trigger it, showing an empty row where a private message this device
* cannot open should say so, or putting a name in front of a line in a two-person room
* where the name is already the title.
*/
class ChatRoomLastMessagePreviewTest {
private val user = "a".repeat(64)
private val alice = "b".repeat(64)
private val bob = "c".repeat(64)
private fun room(
participants: List<String>,
lastChatMessage: ChatRoomLastMessage?,
) = LocalChatRoom(
chatRoom = ChatRoom(
id = "11".repeat(32),
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
),
localParticipants = (participants + user).map { publicKey ->
LocalParticipant(
participant = Participant(
participantPublicKey = publicKey,
chatRoomId = "11".repeat(32),
relayHint = null,
),
profile = Profile(
publicKey = publicKey,
userName = when (publicKey) {
alice -> "Alice"
bob -> "Bob"
else -> "Me"
},
nostrEventId = "d".repeat(64),
),
)
},
lastChatMessage = lastChatMessage,
)
private fun message(
content: String = "hello",
sender: String = alice,
messageType: String = "message",
directMessageRecipientPublicKey: String? = null,
) = ChatRoomLastMessage(
senderPublicKey = sender,
isUserMessage = sender == user,
content = content,
messageType = messageType,
directMessageRecipientPublicKey = directMessageRecipientPublicKey,
createdAt = Instant.fromEpochSeconds(1_000),
)
@Test
fun `a room with nothing said in it has no preview`() {
assertNull(room(listOf(alice), lastChatMessage = null).lastChatMessagePreviewText())
}
/** The other party's name is the row's title already; repeating it says nothing. */
@Test
fun `a two-person room shows the words alone`() {
assertEquals(
"hello",
room(listOf(alice), message(content = "hello")).lastChatMessagePreviewText()
)
}
@Test
fun `a group names who spoke`() {
assertEquals(
"Alice: hello",
room(listOf(alice, bob), message(content = "hello")).lastChatMessagePreviewText()
)
}
/** True in both room shapes: the title never names the reader. */
@Test
fun `the reader's own message is prefixed either way`() {
assertEquals(
"You: hello",
room(listOf(alice), message(content = "hello", sender = user)).lastChatMessagePreviewText()
)
assertEquals(
"You: hello",
room(listOf(alice, bob), message(content = "hello", sender = user)).lastChatMessagePreviewText()
)
}
/**
* Ritual content is written as a predicate for the actor's name, so an authored line
* needs it and reads as nonsense without it.
*/
@Test
fun `an authored ritual line reads as its actor doing it`() {
assertEquals(
"Alice published their share",
room(
listOf(alice, bob),
message(content = "published their share", messageType = ChatMessage.TYPE_DKG_ROUND_1)
).lastChatMessagePreviewText()
)
}
/**
* The group ends up with a key; nobody hands it to them. A name in front of this
* would read as the member having announced it.
*/
@Test
fun `a ritual line nobody authored stands alone`() {
assertEquals(
"The group now has a shared key",
room(
listOf(alice, bob),
message(
content = "The group now has a shared key",
messageType = ChatMessage.TYPE_DKG_COMPLETE
)
).lastChatMessagePreviewText()
)
}
/** A chronicle is not anybody's words either, whichever end of it this device was on. */
@Test
fun `a chronicle line stands alone`() {
assertEquals(
"Caught up on 12 items",
room(
listOf(alice, bob),
message(
content = "Caught up on 12 items",
messageType = ChatMessage.TYPE_CHRONICLE_RECEIVED
)
).lastChatMessagePreviewText()
)
}
/**
* The bystanders' copy of a private message has no content. An empty preview would
* read as the sender having said nothing, so the line says what the group can in
* fact see: that a private message was sent, and to whom.
*/
@Test
fun `a private message this device cannot open says so`() {
assertEquals(
"Alice sent a private message to Bob",
room(
listOf(alice, bob),
message(
content = "",
messageType = ChatMessage.TYPE_DIRECT_MESSAGE,
directMessageRecipientPublicKey = bob,
)
).lastChatMessagePreviewText()
)
}
/** A private message this device can read is a message like any other. */
@Test
fun `a readable private message shows its words`() {
assertEquals(
"Alice: just between us",
room(
listOf(alice, bob),
message(
content = "just between us",
messageType = ChatMessage.TYPE_DIRECT_MESSAGE,
directMessageRecipientPublicKey = user,
)
).lastChatMessagePreviewText()
)
}
/**
* A sender the room has no participant row for -- a member who has since left, or one
* whose profile has not arrived. Falls back to a shortened key rather than dropping
* the attribution, which would silently reassign the words to nobody.
*/
@Test
fun `an unknown sender is named by their key`() {
val stranger = "e".repeat(64)
assertEquals(
"${stranger.take(8)}: hello",
room(listOf(alice, bob), message(sender = stranger)).lastChatMessagePreviewText()
)
}
}