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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user