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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 14:34:19 +02:00
parent 6d8866c2b0
commit a315b86918
5 changed files with 233 additions and 23 deletions

View File

@@ -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"

View File

@@ -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",
)
}
}
}

View File

@@ -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
}