diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt index ba65001c..c46baece 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt @@ -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 + /** + * 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> @Upsert @@ -29,4 +72,4 @@ interface ChatRoomDao { @Delete suspend fun delete(chatRoom: ChatRoom) -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/ChatRoomLastMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/ChatRoomLastMessage.kt new file mode 100644 index 00000000..88cee3ce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/ChatRoomLastMessage.kt @@ -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, +) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt index 74e5c014..021f0f0a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt @@ -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 = 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" } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/intermdiate/ChatRoomLastMessagePreviewTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/intermdiate/ChatRoomLastMessagePreviewTest.kt new file mode 100644 index 00000000..ac4d2f4b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/intermdiate/ChatRoomLastMessagePreviewTest.kt @@ -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, + 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() + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ChatRoomDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ChatRoomDaoJvmTest.kt new file mode 100644 index 00000000..d8a0a767 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ChatRoomDaoJvmTest.kt @@ -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() + ) + + @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) + } +}