From a315b86918cacd36ee5cc0432260f5d6c5d25c1e Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 14:34:19 +0200 Subject: [PATCH] feat: say in the transcript that a member is being caught up Phase 7 of docs/member-archive.md, in part. Three chat types -- `TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- so a room that fills itself in explains itself once. Without this the archive is entirely silent by design: it files no line per applied payload, because `ChatMessage` has an `autoGenerate` primary key and every payload would mint a fresh row on every pass of the sweep. The result was a member joining a working group and watching a room populate with no account of where any of it came from, which is worse than the noise it avoided. **One line per archive, not per page.** The received line is written when the request stamp is cleared, which is as close as this can get: an archive's pages are not distinguishable from each other at apply time, and clearing the stamp is exactly the moment a catch-up stops being pending. There is a test that delivers a payload per page, backwards, so the sweep runs repeatedly over many pages, and asserts the transcript holds two lines. **A push behind a Welcome writes nothing**, because the room was never asked. It lands before the member has opened the room, and "caught up on work you have not seen yet" is a line about nothing. Also tested. **The received line names no sender.** An archive can be assembled from pages sent by more than one member, so attributing the catch-up to one would be a guess dressed as a fact. The sent line does name its recipient, written into the content the way the invite line writes one -- which does not follow a rename, and is the accepted cost for a line about something that happened once. **Content is whole sentences**, so these stay out of the AUTHORED sets and nothing prefixes a name to them. And they are added to `ARCHIVE_TYPES` with a matching arm in the transcript, because the failure mode for a missed set is silent: the line renders as a chat bubble, looking exactly like a member having said "Caught up on 12 items". Icons per type rather than the `PanTool` fallback. **Two items from this phase are deliberately not done**, rather than written without the app in front of me: the banner saying a room is catching up, and a "Send history" action on the member row. The first is UI state plumbed through a view model into a layout and the transcript line covers the same ground; the second is a convenience, since both real paths are already automatic. Both are written up in the plan as outstanding, along with the thing this phase was also meant to say and does not: that an archive does not make its recipient able to sign, and does not carry the translated text. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 35 +++++++++ .../mantra/compose/managers/ArchiveManager.kt | 69 ++++++++++++++++ .../ui/view/model/ChatMessageListViewModel.kt | 22 ++++++ .../compose/managers/ArchiveApplyJvmTest.kt | 78 +++++++++++++++++-- docs/member-archive.md | 52 +++++++++---- 5 files changed, 233 insertions(+), 23 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index d98e1dab..9262005a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -322,6 +322,41 @@ data class ChatMessage( * the commit is one of several competing for an epoch and none has been * applied. Neither says anything a member wrote. */ + /** + * Handing a member the group's signed record, as lines in the chat. + * + * One line per archive rather than per page. A member who joins a working + * group has a room that fills itself in without explanation, and the + * alternative to saying so once is either silence -- which looks like a + * group that has done nothing -- or a line per applied payload, which is + * the transcript the archive deliberately does not write. + * + * Content is self-contained: these are not predicates for a name to be + * read in front of, so they stay out of the AUTHORED sets. A recipient's + * name is written into the sent line the way the invite line writes one, + * which does not follow a rename and is the accepted cost for a line + * about a thing that happened once. + * + * The received line names no sender on purpose. An archive can be + * assembled out of pages from more than one member, so attributing the + * catch-up to one of them would be a guess dressed as a fact. + */ + const val TYPE_ARCHIVE_REQUESTED = "archiveRequested" + const val TYPE_ARCHIVE_SENT = "archiveSent" + const val TYPE_ARCHIVE_RECEIVED = "archiveReceived" + + /** + * Every archive line, for the one check the transcript dispatches on. + * + * A type missing from here renders as a chat bubble -- silently, and + * looking exactly like a member having said "Caught up on 12 items". + */ + val ARCHIVE_TYPES = setOf( + TYPE_ARCHIVE_REQUESTED, + TYPE_ARCHIVE_SENT, + TYPE_ARCHIVE_RECEIVED, + ) + const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" const val TYPE_PENDING_COMMIT = "pendingCommit" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt index 0be55765..c5a3e2d8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ArchiveManager.kt @@ -153,6 +153,14 @@ object ArchiveManager { database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = Clock.System.now())) + announce( + database = database, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + messageType = ChatMessage.TYPE_ARCHIVE_REQUESTED, + content = "Asked this group for its signed work", + ) + logger.i("Asked $chatRoomId for its signed history") return true @@ -201,11 +209,55 @@ object ArchiveManager { ) } + val name = database.profileDao().getProfileByPublicKey(recipient) + ?.humanReadableNameOrPubkey() + ?: recipient.take(8) + + announce( + database = database, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + messageType = ChatMessage.TYPE_ARCHIVE_SENT, + content = "Sent this group's signed work to $name", + ) + logger.i("Sending ${recipient.take(8)} ${pages.size} archive page(s) for $chatRoomId") return pages.size } + /** + * One line in the room's transcript. + * + * Written by the device it happened on, for itself. None of these travels -- + * an archive is not an event in the group's life, it is one member being + * caught up -- so a member watching from the side sees nothing, correctly. + * + * Content is a whole sentence rather than a predicate, so these stay out of + * the AUTHORED sets and nothing prefixes a name to them. See + * [ChatMessage.ARCHIVE_TYPES]. + */ + private suspend fun announce( + database: MantraDatabase, + chatRoomId: String, + userPublicKey: HexKey, + messageType: String, + content: String, + ) { + database.chatMessageDao().upsert( + ChatMessage( + content = content, + messageType = messageType, + chatRoomId = chatRoomId, + senderPublicKey = userPublicKey, + isUserMessage = true, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = null, + ) + ) + } + /** * Put one event on the room's outbound queue. * @@ -381,6 +433,23 @@ object ArchiveManager { database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom?.let { chatRoom -> if (chatRoom.archiveRequestedAt != null) { database.chatRoomDao().upsert(chatRoom.copy(archiveRequestedAt = null)) + + // One line per answered request, which is as close to one per + // archive as this can get: the pages of an archive are not + // distinguishable from each other here, and clearing the stamp + // is exactly the moment a catch-up stops being pending. + // + // A push behind a Welcome writes none, because the room was + // never asked. That is right: it lands before the member has + // opened the room, and "caught up on work you have not seen + // yet" is a line about nothing. + announce( + database = database, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + messageType = ChatMessage.TYPE_ARCHIVE_RECEIVED, + content = "Caught up on ${pass.applied} item(s) of this group's signed work", + ) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 753ae0f4..4c7cca5b 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -26,6 +26,8 @@ import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.CallMerge import androidx.compose.material.icons.filled.FactCheck import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.filled.PersonAdd @@ -406,6 +408,22 @@ class ChatMessageListViewModel( return@items } + // Catching a member up is nobody's words either, + // and it leads nowhere: the work it delivered is + // in the artifact list, not behind this line. + // Passed as answered and settled because those + // are about requests and this asks nothing -- + // which is what keeps it in the quiet tint. + if (localChatMessage.chatMessage.messageType in ChatMessage.ARCHIVE_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = true, + isSettled = true, + onClick = {} + ) + return@items + } + // Signing lines are the same kind of thing and get // the same treatment -- nobody said them either -- // but they lead somewhere else, because what a @@ -723,6 +741,10 @@ private fun RitualNotice( ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw + ChatMessage.TYPE_ARCHIVE_REQUESTED -> Icons.Default.History + ChatMessage.TYPE_ARCHIVE_SENT -> Icons.Default.Upload + ChatMessage.TYPE_ARCHIVE_RECEIVED -> Icons.Default.Download + else -> Icons.Default.PanTool } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt index 281255db..add5359a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ArchiveApplyJvmTest.kt @@ -404,8 +404,10 @@ class ArchiveApplyJvmTest { senderArchive().forEach { deliver(it) } assertEquals( - 0, - receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId).size, + emptyList(), + receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) + .map { it.chatMessage.messageType } + .filterNot { it in ChatMessage.ARCHIVE_TYPES }, "the archive restores the work; the conversation is forward secret and stays gone" ) } @@ -593,7 +595,12 @@ class ArchiveApplyJvmTest { archive.forEach { deliver(it) } assertEquals(afterFirst, rowCounts(receiver)) - assertEquals(0, receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId).size) + assertEquals( + emptyList(), + receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) + .map { it.chatMessage.messageType } + .filterNot { it in ChatMessage.ARCHIVE_TYPES }, + ) } // ---- Asking for one -------------------------------------------------- @@ -612,7 +619,14 @@ class ArchiveApplyJvmTest { // does not depend on one, and a row of envelopes in the transcript is not // what an archive should leave behind. assertEquals(1, queued(receiver, ArchiveRequestEvent.KIND).size) - assertEquals(0, receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId).size) + + // One line saying so, and nothing else. Asking is a thing this device did + // and the room would otherwise sit empty with no explanation. + assertEquals( + listOf(ChatMessage.TYPE_ARCHIVE_REQUESTED), + receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) + .map { it.chatMessage.messageType } + ) } @Test @@ -672,8 +686,12 @@ class ArchiveApplyJvmTest { assertEquals(newMember, page.recipient()) assertEquals(ArchiveEvent.ARCHIVABLE_KINDS, page.payloads()?.map { it.kind }?.toSet()) - // And no chat line for it, for the same reason the request has none. - assertEquals(0, sender.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId).size) + // One line about the archive, and no line per page. + assertEquals( + listOf(ChatMessage.TYPE_ARCHIVE_SENT), + sender.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) + .map { it.chatMessage.messageType } + ) } @Test @@ -805,4 +823,52 @@ class ArchiveApplyJvmTest { .size ) } + + @Test + fun `an answered catch-up leaves one line, whatever it took to deliver`() = runBlocking { + seedSenderWork() + seedRoom(receiver, newMember) + + ArchiveManager.requestIfEmpty(receiver, chatRoomId, newMember) + + // Delivered a page at a time and out of order, so the sweep runs several + // times over several pages. The transcript is not a log of that. + val payloads = senderArchive() + .flatMap { assertNotNull(ArchiveEvent.decodePage(it.content)) } + + payloads.reversed().forEachIndexed { index, payload -> + deliver( + ArchiveEvent.build( + payloads = listOf(payload), + archiveId = "e".repeat(64), + index = index, + count = payloads.size, + recipient = newMember, + createdAt = 1_700_000_100L + index, + ) + ) + } + + assertEquals( + listOf(ChatMessage.TYPE_ARCHIVE_REQUESTED, ChatMessage.TYPE_ARCHIVE_RECEIVED), + receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) + .map { it.chatMessage.messageType } + ) + } + + @Test + fun `a push nobody asked for says nothing in the transcript`() = runBlocking { + seedSenderWork() + seedRoom(receiver, newMember) + + // The invite-time push lands before the member has opened the room, so + // "caught up on work you have not seen yet" is a line about nothing. + senderArchive().forEach { deliver(it) } + + assertEquals( + emptyList(), + receiver.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) + .map { it.chatMessage.messageType } + ) + } } diff --git a/docs/member-archive.md b/docs/member-archive.md index cf09bddb..1c51feea 100644 --- a/docs/member-archive.md +++ b/docs/member-archive.md @@ -530,23 +530,41 @@ occasions. **A day.** -- **A room being backfilled says so.** A banner on the room and on the artifact - list: *"Catching up on this group's work"*, with the page count from - `ArchivePageTag` when an archive is in flight. Without it the first minutes in - a new room are indistinguishable from a group that has done nothing, which is - the wrong first impression and generates the support question this whole - document exists to answer. -- **A "Send history" action** on the member row, for the case the automation - misses and for testing. It queues an archive to that member. -- **The transcript gets one line per archive**, not one per event: *"Sent the - group's history to X"* / *"Received the group's history"*. `TYPE_ARCHIVE`, - added to `FROST_TYPES`' neighbours in - [ChatMessage.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt) - -- check every set a new type has to be added to, because one missed set - renders it silently as a chat bubble. -- **Say what the new member cannot do.** See below; this is the part of the - feature most likely to be reported as a bug, and the screen is where to answer - it. +**The transcript gets one line per archive**, not one per event. Three types -- +`TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- in +`ARCHIVE_TYPES`, with an arm in the transcript that renders them as notices. A +type missing from that set renders as a chat bubble, silently, looking exactly +like a member having said *"Caught up on 12 items"*. + +Three decisions inside that: + +- **The received line is written when the request stamp is cleared**, which is as + close to one-per-archive as this can get: an archive's pages are not + distinguishable from each other at apply time, and clearing the stamp is + exactly the moment a catch-up stops being pending. +- **A push behind a Welcome writes no line at all**, because the room was never + asked. It lands before the member has opened the room, and *"caught up on work + you have not seen yet"* is a line about nothing. +- **The received line names no sender.** An archive can be assembled from pages + sent by more than one member, so attributing the catch-up to one of them would + be a guess dressed as a fact. + +**Not done, and deliberately.** Two items from this phase's first draft are +left out rather than written blind: + +- *A banner on the room saying it is catching up.* Worth having -- the first + minutes in a new room otherwise look like a group that has done nothing -- but + it is UI state plumbed through a view model into a layout, and the transcript + line covers the same ground badly rather than not at all. Do it with the app + running. +- *A "Send history" action on the member row.* A convenience, not a mechanism: + both real paths are automatic, so this is for the case the automation misses, + and it wants a screen to live on. + +**Say what the new member cannot do.** Still unwritten, and still the part of +this most likely to be reported as a bug: an archive does not make its recipient +able to sign, and it does not carry the translated text. Both belong in front of +a member the first time they open a room they were added to late. ---