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

@@ -9,18 +9,61 @@ import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import kotlinx.coroutines.flow.Flow
/**
* The chat-list queries carry the room's newest line with them.
*
* A correlated subquery rather than a `GROUP BY chatRoomId` with `MAX(createdAt)`: an
* [press.mantra.compose.database.model.ChatMessage] timestamp is stored to the second,
* so a burst of ritual lines written in the same second ties, and the aggregate would
* pick one of them arbitrarily. Falling back to `id DESC` breaks the tie on the order
* the rows were actually written, which is the order the transcript shows them in.
*
* A room with nothing said in it sorts on when it was created, so a freshly made room
* appears at the top where the user left it rather than at the bottom under everything.
*/
private const val CHAT_ROOM_WITH_LAST_MESSAGE =
"SELECT ChatRoom.*, " +
"lastMessage.senderPublicKey AS lastMessage_senderPublicKey, " +
"lastMessage.isUserMessage AS lastMessage_isUserMessage, " +
"lastMessage.content AS lastMessage_content, " +
"lastMessage.messageType AS lastMessage_messageType, " +
"lastMessage.directMessageRecipientPublicKey AS lastMessage_directMessageRecipientPublicKey, " +
"lastMessage.createdAt AS lastMessage_createdAt " +
"FROM ChatRoom " +
"LEFT JOIN ChatMessage AS lastMessage ON lastMessage.id = (" +
"SELECT id FROM ChatMessage " +
"WHERE chatRoomId = ChatRoom.id AND deletedAt IS NULL " +
"ORDER BY createdAt DESC, id DESC LIMIT 1" +
") "
private const val ORDER_BY_LAST_ACTIVITY =
"ORDER BY COALESCE(lastMessage.createdAt, ChatRoom.createdAt) DESC, ChatRoom.createdAt DESC"
@Dao
interface ChatRoomDao {
@Transaction
@Query("SELECT * FROM ChatRoom WHERE id = :id AND deletedAt IS NULL")
@Query(CHAT_ROOM_WITH_LAST_MESSAGE + "WHERE ChatRoom.id = :id AND ChatRoom.deletedAt IS NULL")
suspend fun findChatRoomById(id: String): LocalChatRoom?
@Transaction
@Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey AND deletedAt IS NULL")
@Query(
CHAT_ROOM_WITH_LAST_MESSAGE +
"WHERE ChatRoom.userPublicKey = :userPublicKey AND ChatRoom.deletedAt IS NULL " +
ORDER_BY_LAST_ACTIVITY
)
suspend fun getChatRoomListByUserPublicKey(userPublicKey: String): List<LocalChatRoom>
/**
* Re-emits when a message lands as well as when a room changes -- the query reads
* ChatMessage, so Room invalidates it on both, which is what keeps a row's preview
* and its place in the order current without the list asking.
*/
@Transaction
@Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey AND deletedAt IS NULL")
@Query(
CHAT_ROOM_WITH_LAST_MESSAGE +
"WHERE ChatRoom.userPublicKey = :userPublicKey AND ChatRoom.deletedAt IS NULL " +
ORDER_BY_LAST_ACTIVITY
)
fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow<List<LocalChatRoom>>
@Upsert
@@ -29,4 +72,4 @@ interface ChatRoomDao {
@Delete
suspend fun delete(chatRoom: ChatRoom)
}
}

View File

@@ -0,0 +1,25 @@
package press.mantra.compose.database.model.intermdiate
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Instant
/**
* The newest line in a room, as much of it as a chat list row can show.
*
* Not the [press.mantra.compose.database.model.ChatMessage] itself: embedding the entity
* would mean aliasing thirty-odd columns onto every chat-room query, and colliding with
* the room's own `id` and its four timestamps on the way. These six are everything a
* one-line preview and the clock beside it are written from.
*
* Read back as null for a room nothing has been said in yet, which is a different thing
* from a room whose last line has no words -- a private message this device cannot open
* is exactly that, and says so rather than showing an empty row.
*/
data class ChatRoomLastMessage(
val senderPublicKey: HexKey,
val isUserMessage: Boolean,
val content: String,
val messageType: String,
val directMessageRecipientPublicKey: HexKey?,
val createdAt: Instant,
)

View File

@@ -1,15 +1,20 @@
package press.mantra.compose.database.model.intermdiate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.room3.Embedded
import androidx.room3.Relation
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.extensions.memberName
import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
import com.vitorpamplona.quartz.nip01Core.core.HexKey
data class LocalChatRoom(
@Embedded val chatRoom: ChatRoom,
@@ -20,6 +25,17 @@ data class LocalChatRoom(
entityColumns = ["chatRoomId"],
)
val localParticipants: List<LocalParticipant> = emptyList(),
/**
* The room's newest line, or null for a room nothing has been said in yet.
*
* Only the chat-list queries fill this in; every other way of getting a room
* leaves it null rather than paying for a join no screen reads. So a null here
* means "not asked for" as often as it means "nothing said", which is why
* nothing hangs a decision on it beyond what to draw.
*/
@Embedded(prefix = "lastMessage_")
val lastChatMessage: ChatRoomLastMessage? = null,
) {
@Composable
fun RenderChatRoomTitleText() {
@@ -54,4 +70,75 @@ data class LocalChatRoom(
}
}
}
/**
* One line saying what was last said here, under the room's name in the chat list.
*
* A quieter echo of how the transcript renders that same line, so the two never
* disagree about what a room's newest activity was.
*/
@Composable
fun RenderChatRoomLastMessageText() {
Text(
text = lastChatMessagePreviewText() ?: "No messages yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
/**
* The room's newest line as a single line of text, or null if there is none.
*
* Follows the transcript's own dispatch, for the same reasons it does:
*
* - a ritual or chronicle line is nobody's words, and its content is written as a
* predicate for an actor's name -- so the authored ones get that name in front and
* the rest stand alone. Prefixing "a shared key ceremony started" with a member
* would read as them having announced it.
* - a private message this device cannot open has no words to show. Saying that a
* private message was sent, and to whom, is all the group can learn from the line
* and all the preview should claim.
* - 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.
*/
fun lastChatMessagePreviewText(): String? {
val lastChatMessage = lastChatMessage ?: return null
if (lastChatMessage.messageType in ChatMessage.DKG_TYPES ||
lastChatMessage.messageType in ChatMessage.FROST_TYPES ||
lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES
) {
val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
return if (isAuthored) {
"${nameFor(lastChatMessage.senderPublicKey)} ${lastChatMessage.content}"
} else {
lastChatMessage.content
}
}
if (lastChatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE &&
lastChatMessage.content.isBlank()
) {
return "${nameFor(lastChatMessage.senderPublicKey)} sent a private message to " +
nameFor(lastChatMessage.directMessageRecipientPublicKey)
}
val isGroup = localParticipants.count {
it.participant.participantPublicKey != chatRoom.userPublicKey
} > 1
return when {
lastChatMessage.isUserMessage -> "You: ${lastChatMessage.content}"
isGroup -> "${nameFor(lastChatMessage.senderPublicKey)}: ${lastChatMessage.content}"
else -> lastChatMessage.content
}
}
/** "someone" for a message with no recipient -- which no direct message has. */
private fun nameFor(publicKey: HexKey?): String =
publicKey?.memberName(localParticipants, chatRoom.userPublicKey) ?: "someone"
}

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()
)
}
}

View File

@@ -0,0 +1,177 @@
package press.mantra.compose.database.dao
import androidx.room3.Room
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.time.Instant
/**
* The chat list's order and its previews both come out of one query, so a single wrong
* join reads as two unrelated bugs: rooms sorted by nothing anybody can see, and a row
* quoting a message that is not the newest one in it.
*
* The parts that can only be checked against a real SQLite are here -- which row the
* correlated subquery picks, and how the ordering treats a room nothing has been said
* in. What the picked row is then rendered as is [press.mantra.compose.database.model.intermdiate.LocalChatRoom]'s
* own business and is tested against it directly.
*/
class ChatRoomDaoJvmTest {
private val db: MantraDatabase = getRoomDatabase(
Room.inMemoryDatabaseBuilder<MantraDatabase>()
)
@AfterTest
fun closeDb() = db.close()
private val user = "a".repeat(64)
private val quiet = "11".repeat(32)
private val busy = "22".repeat(32)
private val stale = "33".repeat(32)
/** Rooms created oldest-first, so creation order and activity order disagree. */
private suspend fun seedRooms() {
val nostrEventId = "c".repeat(64)
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = user,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
listOf(stale to 100L, busy to 200L, quiet to 300L).forEach { (id, createdAt) ->
db.chatRoomDao().upsert(
ChatRoom(
id = id,
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
createdAt = Instant.fromEpochSeconds(createdAt),
)
)
}
}
private suspend fun line(
chatRoomId: String,
content: String = "a line",
createdAt: Long,
deletedAt: Long? = null,
): Long = db.chatMessageDao().upsert(
ChatMessage(
content = content,
chatRoomId = chatRoomId,
senderPublicKey = user,
isUserMessage = true,
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = null,
createdAt = Instant.fromEpochSeconds(createdAt),
deletedAt = deletedAt?.let { Instant.fromEpochSeconds(it) },
)
)
private suspend fun rooms() = db.chatRoomDao().getChatRoomListByUserPublicKey(user)
@Test
fun `rooms are ordered by their newest message`() = runBlocking {
seedRooms()
line(stale, createdAt = 1_000)
line(busy, createdAt = 9_000)
line(quiet, createdAt = 5_000)
assertEquals(listOf(busy, quiet, stale), rooms().map { it.chatRoom.id })
}
/**
* A room nothing has been said in still has to land somewhere, and the only time it
* has is its own. Sorting it last regardless would bury a room the user just made
* under every conversation they have ever had.
*/
@Test
fun `a room with no messages sorts on when it was created`() = runBlocking {
seedRooms()
line(stale, createdAt = 50)
line(busy, createdAt = 250)
// quiet was created at 300, so it outranks both.
assertEquals(listOf(quiet, busy, stale), rooms().map { it.chatRoom.id })
assertNull(rooms().first().lastChatMessage)
}
@Test
fun `the newest line in a room is the one carried`() = runBlocking {
seedRooms()
line(busy, content = "older", createdAt = 1_000)
line(busy, content = "newest", createdAt = 2_000)
line(quiet, content = "someone else's room", createdAt = 3_000)
val lastChatMessage = rooms().first { it.chatRoom.id == busy }.lastChatMessage
assertNotNull(lastChatMessage)
assertEquals("newest", lastChatMessage.content)
assertEquals(Instant.fromEpochSeconds(2_000), lastChatMessage.createdAt)
}
/**
* Timestamps are stored to the second, so a burst written in one second ties. The
* transcript orders those on their row ids, and the preview has to agree with it --
* a room whose last two lines arrived together must not quote whichever one SQLite
* happened to reach first.
*/
@Test
fun `lines written in the same second break the tie on write order`() = runBlocking {
seedRooms()
line(busy, content = "first", createdAt = 1_000)
line(busy, content = "second", createdAt = 1_000)
line(busy, content = "third", createdAt = 1_000)
assertEquals("third", rooms().first { it.chatRoom.id == busy }.lastChatMessage?.content)
}
@Test
fun `a deleted line is not the room's last message`() = runBlocking {
seedRooms()
line(busy, content = "kept", createdAt = 1_000)
line(busy, content = "deleted", createdAt = 2_000, deletedAt = 2_500)
assertEquals("kept", rooms().first { it.chatRoom.id == busy }.lastChatMessage?.content)
}
/** One room's traffic must not become another room's preview. */
@Test
fun `the last message is scoped to its own room`() = runBlocking {
seedRooms()
line(busy, content = "in the busy room", createdAt = 9_000)
assertEquals("in the busy room", rooms().first { it.chatRoom.id == busy }.lastChatMessage?.content)
assertNull(rooms().first { it.chatRoom.id == quiet }.lastChatMessage)
assertNull(rooms().first { it.chatRoom.id == stale }.lastChatMessage)
}
/** The single-room lookup reads the same shape, so a detail screen sees what the list did. */
@Test
fun `finding one room carries its last message too`() = runBlocking {
seedRooms()
line(busy, content = "newest", createdAt = 2_000)
assertEquals("newest", db.chatRoomDao().findChatRoomById(busy)?.lastChatMessage?.content)
assertNull(db.chatRoomDao().findChatRoomById(quiet)?.lastChatMessage)
}
}