Merge branch 'mantra' into claude/home-chat-previews-e1913f

Two conflicts, and both are the same collision: mantra took schema v15 while
this branch was also calling its migration v15.

**The version number.** mantra's v15 adds `ChatRoom.joinedGroupAt` with a manual
MIGRATION_14_15, because half of what it does -- deleting the placeholder chat
lines already written for messages sent before this device joined -- is not a
shape Room generates. That is the older claim on the number and it keeps it. The
index migration here becomes `AutoMigration(from = 15, to = 16)` and the database
goes to v16, so a device that has already run v15 gets the index on top of it
rather than the two fighting over one version.

`15.json` is resolved to mantra's wholesale -- an add/add conflict between two
unrelated schemas is not something to merge line by line -- and 16.json is
regenerated from the build. Checked rather than assumed: 16.json differs from
15.json in exactly one place, `index_ChatMessage_chatRoomId`, and no table's
fields, createSql or other indices move.

**mantra's new membership lines needed handling here**, and nothing would have
told me: `98f766f` added `TYPE_MEMBER_INVITED`, `TYPE_MEMBER_INVITE_SENT` and
`TYPE_MEMBER_INVITE_FAILED`, which the transcript renders as system notices. The
chat list preview dispatches on the same question the transcript does -- is this
somebody's words -- and a type missing from that check falls through to the chat
bubble branch. A room whose newest line was an invite would have previewed as
"Alice: Invited Bob to the group", which reads as Alice having said it. Exactly
the failure `ChatMessage.MEMBERSHIP_TYPES`' own comment warns about, one screen
over from where it was written.

So `MEMBERSHIP_TYPES` joins the ritual and chronicle sets in
`lastChatMessagePreviewText`. They are not in the AUTHORED sets -- their content
is a whole sentence with the invitee's name already in it -- so they stand alone,
which is what the transcript does with them too. One new test, over all three
types rather than a representative one, since the set is the thing being relied
on.

Nothing else needed reconciling. mantra's pre-join fix filters at indexing time
and deletes the rows outright, so the last-message subquery sees fewer rows and
needs no `memberSince` clause of its own to stay in step with the transcript.

550 jvm tests and 319 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 20:12:49 +02:00
24 changed files with 7054 additions and 109 deletions

View File

@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 15,
version = 16,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -256,11 +256,19 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// rows in the same breath, so this is a manual migration passed to the
// builder rather than an entry here. See MIGRATION_13_14.
//
// v15 adds an index on ChatMessage.chatRoomId. No column changes and no
// v15 adds the nullable ChatRoom.joinedGroupAt, which says when this
// device became a member and so which of the group's messages were never
// its to read. Adding a nullable column is a shape Room migrates itself;
// deleting the placeholder chat lines already written for those messages
// is not, and a member who joined a busy room is looking at a screenful of
// them. Manual for that half, so it too is passed to the builder rather
// than listed here. See MIGRATION_14_15.
//
// v16 adds an index on ChatMessage.chatRoomId. No column changes and no
// rows move -- the chat list now looks up each room's newest line, and
// without the index that lookup reads every message on the device. Room
// creates an index on its own.
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
]
)
@ColumnTypeConverters(MantraConverters::class)

View File

@@ -5,6 +5,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import press.mantra.compose.database.migrations.MIGRATION_3_4
import press.mantra.compose.database.migrations.MIGRATION_9_10
import press.mantra.compose.database.migrations.MIGRATION_13_14
import press.mantra.compose.database.migrations.MIGRATION_14_15
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@@ -18,12 +19,13 @@ fun getRoomDatabase(
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
): press.mantra.compose.database.MantraDatabase {
return builder
// Everything else Room generates itself. These three move data rather than
// Everything else Room generates itself. These four move data rather than
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
// chat rows, 9->10 copies a session's per-event columns onto the items
// table before dropping them, and 13->14 renames a column and rewrites the
// chat rows that named it the old way.
.addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14)
// table before dropping them, 13->14 renames a column and rewrites the
// chat rows that named it the old way, and 14->15 adds a column and deletes
// the chat rows written for messages from before this device joined.
.addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()

View File

@@ -21,6 +21,7 @@ import press.mantra.compose.database.model.Participant
import press.mantra.compose.exceptions.MarmotMissingChatGroupException
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.extensions.exporterSecret
import press.mantra.compose.extensions.shortened
import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW
import press.mantra.compose.nostr.MarmotDelivery
@@ -81,12 +82,19 @@ abstract class MarmotOutboundDao(
)
// Save Chat Room
//
// Member since the group existed, because this device is what created it:
// there is no epoch of this group that predates us, and so nothing in it
// for ChatRoom.predatesMembership to hold back.
val createdAt = Clock.System.now()
val chatRoom = ChatRoom(
id = nostrGroupId,
userPublicKey = userPublicKey,
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
subject = name,
description = description
description = description,
joinedGroupAt = createdAt,
createdAt = createdAt,
)
database.chatRoomDao().upsert(
chatRoom
@@ -231,6 +239,12 @@ abstract class MarmotOutboundDao(
}.onFailure {
logger.e("Failed to invite $peerPublicKey to ${localChatRoom.chatRoom.id}", it)
notAdded.add(peerPublicKey)
announceInviteFailed(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
peerPublicKey = peerPublicKey,
reason = it.message,
)
}
}
@@ -287,9 +301,30 @@ abstract class MarmotOutboundDao(
)
)
// One line each, as `inviteMember` writes for the invites it makes -- this
// path does its own commit and never goes through it. Written before the
// Welcomes rather than after, so a delivery that fails has an invite to be
// read against instead of a failure on its own.
peers.forEach { (peerPublicKey, _) ->
announceMembership(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
messageType = ChatMessage.TYPE_MEMBER_INVITED,
content = "Invited ${memberName(peerPublicKey)} to the group",
)
}
val welcomeBytes = commitResult.welcomeBytes
if (welcomeBytes == null) {
logger.e("Batched commit for ${localChatRoom.chatRoom.id} produced no welcome")
peers.forEach { (peerPublicKey, _) ->
announceInviteFailed(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
peerPublicKey = peerPublicKey,
reason = "the group produced no invitation to send",
)
}
return peers.map { it.first }
}
@@ -300,17 +335,20 @@ abstract class MarmotOutboundDao(
val notAdded = mutableListOf<HexKey>()
peers.forEach { (peerPublicKey, peerKeyPackage) ->
runCatching {
deliveryWelcome(
nostrGroupId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
welcomeBytes = welcomeBytes,
peerKeyPackageEventId = peerKeyPackage.id,
relays = relays,
createdAt = Clock.System.now()
)
}.onFailure {
logger.e("Failed to deliver the welcome to $peerPublicKey", it)
// `deliveryWelcome` swallows what it catches and reports it as a
// transcript line instead, so its answer is what says whether this peer
// was reached -- a runCatching here would see nothing to catch.
val delivered = deliveryWelcome(
nostrGroupId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
welcomeBytes = welcomeBytes,
peerKeyPackageEventId = peerKeyPackage.id,
relays = relays,
createdAt = Clock.System.now()
)
if (!delivered) {
logger.e("Failed to deliver the welcome to $peerPublicKey")
notAdded.add(peerPublicKey)
}
}
@@ -318,6 +356,100 @@ abstract class MarmotOutboundDao(
return notAdded
}
/**
* One line in the room's transcript, about a membership change.
*
* The same shape as `ChronicleManager.announce`, and for the same reason: this
* is the device saying what it just did, not an event anybody sent. Content is
* a whole sentence, so nothing prefixes a name to it -- see
* [ChatMessage.MEMBERSHIP_TYPES].
*/
private suspend fun announceMembership(
chatRoomId: HexKey,
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,
)
)
}
/**
* What to call an invitee on a membership line.
*
* Written into the content rather than resolved at render time, because these
* lines are about something that happened once and the AUTHORED-set machinery
* that follows a rename is for lines with an actor. A member being invited has
* a Profile row by construction -- Participant.participantPublicKey is a
* foreign key onto it -- so the fallback is for the delivery path, which runs
* long after the invite and reads its peer back out of a key package.
*/
private suspend fun memberName(publicKey: HexKey): String =
database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey()
?: publicKey.shortened()
/**
* The Welcome for an invite made earlier has gone out.
*
* Written by the deferred delivery site only -- `DatabaseNostrRepository`, once
* a relay has acknowledged the commit. An invite into a group that was still
* just its creator sends its Welcome in the same breath as the invite, so its
* [ChatMessage.TYPE_MEMBER_INVITED] line already says this and a second one
* would only be the same second told twice. See [ChatMessage.MEMBERSHIP_TYPES].
*/
suspend fun announceInviteSent(
chatRoomId: HexKey,
userPublicKey: HexKey,
peerKeyPackageEventId: HexKey,
) {
val peerPublicKey = database.marmotKeyPackageDao()
.getMarmotKeyPackageById(peerKeyPackageEventId)?.publicKey
announceMembership(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_MEMBER_INVITE_SENT,
content = peerPublicKey?.let { "Sent ${memberName(it)} their invitation" }
?: "Sent the invitation",
)
}
/**
* An invite did not make it, and nothing else will say so.
*
* The reason is put in the line because it is the only copy the user gets: the
* screen that asked for the invite is gone by the time the Welcome is
* delivered, and the failures that happen before it closes take the whole
* transaction -- and the invite line inside it -- down with them. See
* `DatabaseChatRepository.inviteMember`.
*/
suspend fun announceInviteFailed(
chatRoomId: HexKey,
userPublicKey: HexKey,
peerPublicKey: HexKey?,
reason: String?,
) {
val who = peerPublicKey?.let { "${memberName(it)}'s invitation" } ?: "the invitation"
val why = reason?.takeIf { it.isNotBlank() }?.let { ": $it" } ?: ""
announceMembership(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_MEMBER_INVITE_FAILED,
content = "Couldn't send $who$why",
)
}
private suspend fun inviteMember(
nostrGroupId: HexKey,
mlsGroup: MlsGroup,
@@ -357,6 +489,20 @@ abstract class MarmotOutboundDao(
"KeyPackage credential identity does not match memberPubKey"
}
// Say so now, not when the Welcome eventually goes out. On the deferred path
// that is a relay round trip away and may never happen at all, and until this
// line existed the room showed nothing whatsoever in the meantime -- an invite
// that was queued, one that failed to reach the wire and one that was never
// made all looked identical from the transcript. Inside the caller's
// transaction, so an invite that does not survive `addMember` leaves no claim
// that it did.
announceMembership(
chatRoomId = nostrGroupId,
userPublicKey = userPublicKey,
messageType = ChatMessage.TYPE_MEMBER_INVITED,
content = "Invited ${memberName(peerPublicKey)} to the group",
)
val retainedBefore = mlsGroup.retainedSecrets()
val commitResult = mlsGroup.addMember(
peerKeyPackage.tlsEncodedMarmotKeyPackage
@@ -462,6 +608,16 @@ abstract class MarmotOutboundDao(
}
}
/**
* Put the invitee's Welcome on the outbound queue.
*
* Returns whether it got there. The caller cannot see that any other way -- the
* body swallows what it catches, deliberately, because on the deferred path this
* runs from a relay acknowledgement with no invite screen left to fail back to.
* What replaced reporting nothing at all is the [ChatMessage.TYPE_MEMBER_INVITE_FAILED]
* line written below, which outlives the screen and is the only account of a
* Welcome that never went out.
*/
suspend fun deliveryWelcome(
nostrGroupId: HexKey,
userPublicKey: HexKey,
@@ -469,7 +625,7 @@ abstract class MarmotOutboundDao(
peerKeyPackageEventId: HexKey,
relays: List<String>,
createdAt: Instant
) {
): Boolean {
try {
val welcomeBase64 = Base64.encode(
source = welcomeBytes
@@ -507,22 +663,14 @@ abstract class MarmotOutboundDao(
)
)
// The invite's own transcript line is not written here. It used to be, and
// that put nothing in the room for an invite into an established group --
// where this runs a relay round trip later, if at all, and never at all if
// the ack does not come. It also hung off the two lookups below, so a
// missing key package or profile cost the line and not just the name.
// `inviteMember` writes it when the invite is made instead; what this
// function still owes the room is the failure below.
database.marmotKeyPackageDao().getMarmotKeyPackageById(peerKeyPackageEventId)?.let { marmotKeyPackage ->
database.profileDao().getProfileByPublicKey(marmotKeyPackage.publicKey)?.let { profile ->
// Save chatMessage for the invite...
database.chatMessageDao().upsert(
ChatMessage(
content = "Invited ${profile.humanReadableNameOrPubkey() ?: "participant"} to chat", // use profile.humanReadable...
chatRoomId = nostrGroupId,
senderPublicKey = userPublicKey,
isUserMessage = true, // TODO: This is information message...
giftWrapPayloadId = welcomeEventId,
marmotGroupEventId = null,
marmotInnerEventId = null
)
)
}
// The group's signed work, offered to the member being invited.
// Nothing else will ever show it to them: MLS gives a joiner no
// history, and a group-signed event never travels -- every device
@@ -550,8 +698,26 @@ abstract class MarmotOutboundDao(
recipient = marmotKeyPackage.publicKey,
)
}
return true
} catch (e: Throwable) {
logger.e("Failed to deliver welcome:", e)
// The room's only account of this. Its own runCatching because the
// failure being reported may well be the database, and a throw from
// here would be one the callers on the immediate path do not expect --
// taking the invite's transaction down over a line about it.
runCatching {
announceInviteFailed(
chatRoomId = nostrGroupId,
userPublicKey = userPublicKey,
peerPublicKey = database.marmotKeyPackageDao()
.getMarmotKeyPackageById(peerKeyPackageEventId)?.publicKey,
reason = e.message,
)
}.onFailure { logger.e("Failed to record the undelivered welcome:", it) }
return false
}
}

View File

@@ -706,6 +706,14 @@ abstract class NostrDao(
description = group.currentMarmotData()?.description?.ifBlank { null },
initialGiftWrapPayloadId = decryptedGiftWrapPayload.id,
createdAt = decryptedGiftWrapPayload.createdAt,
// The Welcome's own `created_at`, which the
// inviter stamps as it mints the Welcome out of
// the Add commit that made us a member. That
// commit is what created the epoch we are joining
// at, so it is the group's clock on when this
// room's history stops being ours to read. See
// ChatRoom.predatesMembership.
joinedGroupAt = decryptedGiftWrapPayload.createdAt,
mlsGroupState = group.saveState().encodeTls().toHex(),
)
)
@@ -1145,6 +1153,13 @@ abstract class NostrDao(
* see [reindexMarmotGroupEvents]. Throws for a room this device cannot process
* the event against at all, which the caller decides what to do about: a first
* delivery lets it roll back its transaction, a replay logs it and moves on.
*
* An event from before this device joined is not read and not filed -- see
* [ChatRoom.predatesMembership]. Nothing about it is this device's: not the
* epoch key it was encrypted under, and so not the message either. Reading it
* anyway is how a room a member was invited to yesterday opened on a screenful
* of "Undecryptable Message" above the conversation, one line for every
* message the group had sent before they arrived.
*/
private suspend fun indexMarmotGroupEvent(
groupEvent: GroupEvent,
@@ -1154,6 +1169,11 @@ abstract class NostrDao(
val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)
?: throw MarmotMissingChatGroupException("Couldn't find chatRoom for ${groupEvent.id}")
if (localChatRoom.chatRoom.predatesMembership(Instant.fromEpochSeconds(groupEvent.createdAt))) {
logger.d("${groupEvent.id} predates this device joining $chatRoomId, nothing to index")
return
}
// Through the cache rather than rebuilt here, so the secret
// tree's skipped-generation keys survive from one message to
// the next. Two events published in the same instant arrive in
@@ -1314,16 +1334,22 @@ abstract class NostrDao(
* recovered by one pass can be what lets the next read the messages that were
* waiting on it.
*
* Events from before this device joined are left out of the sweep entirely --
* see [ChatRoom.predatesMembership]. They are the one part of the backlog a
* replay can say something about in advance: no pass will ever read them, so
* replaying them only spends a refused decrypt each time and reports every one
* of them as a failure, on a room where nothing is wrong.
*
* What this cannot do is recover a message whose key is gone: an application
* message the ratchet has already advanced past, or one from an epoch that
* predates this device joining. Those stay unreadable however often they are
* replayed.
* message the ratchet has already advanced past. Those stay unreadable however
* often they are replayed.
*/
open suspend fun reindexMarmotGroupEvents(
chatRoomId: String,
activeKeyPair: KeyPair,
): MarmotReindexReport {
val userPublicKey = activeKeyPair.pubKey.toHex()
val chatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)?.chatRoom
// The query's LIKE only narrows; the h tag is what decides the room. Sorted
// by CommitOrdering's own comparator rather than left in createdAt order, so
@@ -1338,7 +1364,20 @@ abstract class NostrDao(
}
.sortedWith(CommitOrdering.comparator)
logger.i("Reindex $chatRoomId: ${groupEvents.size} stored group event(s)")
// Held back rather than dropped from the report: they are counted in
// `stored` and again on their own, so the screen can say a room is mostly
// older than the member reading it instead of calling those events read.
//
// A room with no row to ask keeps every event, so a replay against one
// does what it always did rather than quietly finding nothing to do.
val (readable, predatingMembership) = groupEvents.partition { groupEvent ->
chatRoom?.predatesMembership(Instant.fromEpochSeconds(groupEvent.createdAt)) != true
}
logger.i(
"Reindex $chatRoomId: ${groupEvents.size} stored group event(s), " +
"${predatingMembership.size} from before this device joined"
)
// Held commits are what a replay is most often for, and they are only ever
// cleared by one applying -- see MarmotInboundManager.forgetPendingCommits.
@@ -1351,7 +1390,8 @@ abstract class NostrDao(
return MarmotReindexSweep.run(
stored = groupEvents.size,
unresolved = groupEvents.filterNot { it.id in resolved },
predatingMembership = predatingMembership.size,
unresolved = readable.filterNot { it.id in resolved },
replay = { groupEvent ->
indexMarmotGroupEvent(
groupEvent = groupEvent,

View File

@@ -0,0 +1,58 @@
package press.mantra.compose.database.migrations
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import press.mantra.compose.database.model.ChatMessage
/**
* Records when this device joined each room, and clears the lines it wrote for
* messages it was never able to read.
*
* A member added to a group is given the key schedule from their own epoch
* forward and nothing before it. Every kind:445 the group published earlier is
* still on the relays, still syncs down, and is still unreadable -- so the room
* they opened for the first time led with a run of "Undecryptable Message",
* one per message sent before they arrived, above the conversation they were
* actually invited to.
*
* Two changes, one shape and one data, which is why this is a manual migration
* rather than an `AutoMigration` Room could generate:
*
* - `ChatRoom.joinedGroupAt` says when this device became a member, which is
* what `ChatRoom.predatesMembership` reads to leave those events alone. It
* is left null here rather than backfilled from `createdAt`: null already
* means "ask `createdAt`" -- see `ChatRoom.memberSince` -- and copying the
* value would turn a fallback into a claim this migration is in no position
* to make.
* - The placeholder lines already written for those events are deleted. Fixing
* the write path only stops the next one; nothing rewrites a line that is
* already in the transcript, so a member who joined last week would go on
* seeing their run of them forever.
*
* Only the two types in [ChatMessage.UNRESOLVED_MARMOT_TYPES] are deleted, and
* only where the group event behind them predates the room. Those lines say
* nothing by design -- they stand in for an event that was never read -- so
* removing one loses nothing, while every other line is the final word on its
* group event and is left alone. The group events themselves stay: this is
* about what the room shows, not about forgetting what arrived.
*
* `createdAt` is compared rather than `joinedGroupAt` because the column was
* added in this same migration and is null for every row in the database being
* migrated. It is the same comparison `memberSince` falls back to.
*/
val MIGRATION_14_15 = object : Migration(14, 15) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `ChatRoom` ADD COLUMN `joinedGroupAt` INTEGER")
val placeholderTypes = ChatMessage.UNRESOLVED_MARMOT_TYPES.joinToString(", ") { "'$it'" }
connection.execSQL(
"DELETE FROM `ChatMessage` WHERE `messageType` IN ($placeholderTypes) " +
"AND `marmotGroupEventId` IN (" +
"SELECT `MarmotGroupEvent`.`id` FROM `MarmotGroupEvent` " +
"JOIN `ChatRoom` ON `ChatRoom`.`id` = `MarmotGroupEvent`.`chatRoomId` " +
"WHERE `MarmotGroupEvent`.`createdAt` < `ChatRoom`.`createdAt`)"
)
}
}

View File

@@ -363,6 +363,49 @@ data class ChatMessage(
TYPE_CHRONICLE_RECEIVED,
)
/**
* Adding a member to the group, as lines in the group's chat.
*
* An invite is two separate things, and in an established group they can be
* minutes apart: the membership change this device commits, and the Welcome
* that lets the invitee in going on the wire. The second waits on a relay
* acknowledging the commit -- see docs/marmot-membership.md -- and it is the
* one that can fail after the screen that asked for it has closed, which
* leaves the transcript as the only place able to say so.
*
* So [TYPE_MEMBER_INVITED] is written the moment the invite is made, and
* [TYPE_MEMBER_INVITE_SENT] only where the Welcome is delivered later than
* that. Where the two happen together -- a group that is still only its
* creator has nobody to inform, so its Welcome goes out immediately -- there
* is one line, because there was one event.
*
* Written by the inviter's device, for itself. None of these travels: what
* the group receives is the commit, and each member's transcript is written
* from what it made of that. A member watching from the side sees nothing
* here, 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. The invitee's name
* is written into the content the way the chronicle's is, which does not
* follow a rename and is the accepted cost for a line about a thing that
* happened once.
*/
const val TYPE_MEMBER_INVITED = "memberInvited"
const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent"
const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed"
/**
* Every membership line, for the one check the transcript dispatches on.
*
* A type missing from here renders as a chat bubble -- silently, and looking
* exactly like the inviter having said "Invited Bob to the group".
*/
val MEMBERSHIP_TYPES = setOf(
TYPE_MEMBER_INVITED,
TYPE_MEMBER_INVITE_SENT,
TYPE_MEMBER_INVITE_FAILED,
)
const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer"
const val TYPE_PENDING_COMMIT = "pendingCommit"

View File

@@ -79,6 +79,24 @@ data class ChatRoom(
val leftGroupAt: Instant? = null,
/**
* When this device became a member of the group, or null for a room that
* predates the column.
*
* The pair to [leftGroupAt], and the thing [memberSince] is really asking
* for. Written from the moment the group's own clock says our epoch began:
* the Welcome's `created_at`, which the inviter stamps as it mints the
* Welcome out of the Add commit that made us a member, or the room's own
* creation for a group this device started at epoch 0.
*
* Stored rather than read off [createdAt] because the two are only
* incidentally equal. [createdAt] is row bookkeeping -- when this device
* first wrote the row down -- and the question asked here decides which of
* the group's messages are ours to read at all. That is not a fact to leave
* hanging off a timestamp somebody could reasonably repurpose.
*/
val joinedGroupAt: Instant? = null,
/**
* When this device last asked the group for its signed history, or null if
* it never has or the answer has since arrived.
@@ -144,6 +162,43 @@ data class ChatRoom(
}
/**
* The moment this device joined, falling back to when it wrote the room down.
*
* A room joined before [joinedGroupAt] existed has no recorded answer, and
* [createdAt] is both the best one available and the one every path that
* writes [joinedGroupAt] would have written anyway: a joiner's row is created
* from the Welcome, and a creator's when it creates the group.
*/
val memberSince: Instant get() = joinedGroupAt ?: createdAt
/**
* Whether something the group published at [publishedAt] belongs to an epoch
* this device was never in.
*
* MLS gives a joiner the key schedule from their own epoch forward and
* nothing before it, so a kind:445 older than [memberSince] cannot be read
* now and cannot be read later -- not by waiting, and not by replaying it.
* The point of asking is to leave those alone rather than file a line saying
* a message arrived that nobody can show.
*
* Time is the only thing to ask it of. The epoch a kind:445 was encrypted
* under is inside the outer layer, so an event this device cannot decrypt
* cannot be asked what epoch it is from, and "before we joined", "from an
* epoch we have not caught up to" and "from an epoch that fell out of the
* retention window" all look identical from the outside. What separates the
* first from the other two is that it was published before the group made
* the epoch we joined at.
*
* Strictly before, so an event stamped in the same second as our Welcome is
* still read. The error worth avoiding runs one way: an unreadable event
* costs a wasted decrypt, and a discarded readable one is a message the
* member never sees. The commit that added us sits exactly on that boundary
* and is unreadable by construction -- it is the last act of the epoch
* before ours -- so a room may still show one placeholder for it.
*/
fun predatesMembership(publishedAt: Instant): Boolean = publishedAt < memberSince
fun toMlsGroup(): MlsGroup? {
return mlsGroupState?.let {
return MlsGroup.restore(

View File

@@ -99,10 +99,11 @@ data class LocalChatRoom(
*
* 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 ritual, chronicle or membership line is nobody's words. The authored ones
* have content written as a predicate for an actor's name, so they get that name
* in front; the rest are whole sentences already and stand alone. Prefixing "a
* shared key ceremony started" with a member would read as them having announced
* it, and prefixing "Invited Bob to the group" with one reads as them saying 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.
@@ -114,7 +115,8 @@ data class LocalChatRoom(
if (lastChatMessage.messageType in ChatMessage.DKG_TYPES ||
lastChatMessage.messageType in ChatMessage.FROST_TYPES ||
lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES
lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES ||
lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES
) {
val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES

View File

@@ -7,17 +7,23 @@ package press.mantra.compose.database.model.types
* can say what happened rather than leaving the user to guess from the message
* list whether anything moved.
*
* @param stored every kind:445 held locally for the room.
* @param unresolved how many of those had nothing to show for them, or only a
* @param stored every kind:445 held locally for the room, [predatingMembership]
* included. They are held, so they are counted.
* @param predatingMembership how many of [stored] the group published before this
* device joined, which a replay does not touch -- see
* `ChatRoom.predatesMembership`. Reported rather than folded into [stored]
* silently, because "20 events, all read" is not true of a room where 15 of them
* were never this device's to read, and a member who was invited into an old
* room deserves the difference said out loud rather than left as a discrepancy.
* @param unresolved how many of the rest had nothing to show for them, or only a
* placeholder line -- the ones a replay was allowed to touch.
* @param recovered how many of [unresolved] came out with something to show:
* a message, an applied commit, an entity added to the group's library.
* @param failed how many threw while being replayed. Expected to be non-zero
* on a room with events from before this device joined, whose epoch secrets it
* never held and never will.
* @param failed how many threw while being replayed.
*/
data class MarmotReindexReport(
val stored: Int = 0,
val predatingMembership: Int = 0,
val unresolved: Int = 0,
val recovered: Int = 0,
val failed: Int = 0,

View File

@@ -168,11 +168,30 @@ class DatabaseChatRepository(
peerPublicKey: HexKey,
peerKeyPackage: MarmotKeyPackage
) {
database.marmotOutboundDao().inviteMemberToChatRoom(
localChatRoom = localChatRoom,
peerPublicKey = peerPublicKey,
peerKeyPackage = peerKeyPackage
)
try {
database.marmotOutboundDao().inviteMemberToChatRoom(
localChatRoom = localChatRoom,
peerPublicKey = peerPublicKey,
peerKeyPackage = peerKeyPackage
)
} catch (e: Throwable) {
// Outside the transaction that just went down, which is the point: the
// invite line `inviteMember` writes went with it, so an invite refused
// for want of MLS state or over a mismatched credential left the room
// with nothing at all to show. The caller still gets the throw and
// still puts its own message on the invite screen -- this is the copy
// that is still there tomorrow.
logger.e("Failed to invite $peerPublicKey to ${localChatRoom.chatRoom.id}", e)
runCatching {
database.marmotOutboundDao().announceInviteFailed(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
peerPublicKey = peerPublicKey,
reason = e.message,
)
}.onFailure { logger.e("Failed to record the refused invite:", it) }
throw e
}
}
override suspend fun addMembers(
localChatRoom: LocalChatRoom,

View File

@@ -439,15 +439,45 @@ class DatabaseNostrRepository(
database.chatRoomDao().findChatRoomById(marmotCommitResult.chatRoomId)?.let { localChatRoom ->
// TODO: Update status of participant Invitation.PENDING -> Invitation.SENT
logger.d("localChatRoom: $localChatRoom")
marmotCommitResult.welcomeBytes?.let { welcomeBytes ->
logger.d("welcomeBytes: ${welcomeBytes.toHex()}")
database.marmotOutboundDao().deliveryWelcome(
nostrGroupId = marmotCommitResult.chatRoomId,
val welcomeBytes = marmotCommitResult.welcomeBytes
if (welcomeBytes == null) {
// The commit was acknowledged and there is nothing to let
// the invitee in with, so this invite is over. Nobody is
// waiting on the answer by now -- the screen that asked
// closed when the commit was made -- which is why it goes
// in the room rather than to a caller.
database.marmotOutboundDao().announceInviteFailed(
chatRoomId = marmotCommitResult.chatRoomId,
userPublicKey = marmotCommitResult.userPublicKey,
peerPublicKey = database.marmotKeyPackageDao()
.getMarmotKeyPackageById(marmotCommitResult.peerKeyPackageEventId)
?.publicKey,
reason = "the commit carried no invitation",
)
return@let
}
logger.d("welcomeBytes: ${welcomeBytes.toHex()}")
val delivered = database.marmotOutboundDao().deliveryWelcome(
nostrGroupId = marmotCommitResult.chatRoomId,
userPublicKey = marmotCommitResult.userPublicKey,
welcomeBytes = welcomeBytes,
peerKeyPackageEventId = marmotCommitResult.peerKeyPackageEventId,
createdAt = marmotCommitResult.createdAt,
relays = Relays.DefaultDMRelayList.map { it.url } // TODO: Get localChatRoom relays...
)
// This is the deferred delivery site, and the only one where
// the Welcome goes out later than the invite that made it --
// so it is the only one that owes the room a second line. An
// invite whose room was still just its creator sent its
// Welcome in the same breath and said so once already. A
// failure needs nothing here: `deliveryWelcome` files its own.
if (delivered) {
database.marmotOutboundDao().announceInviteSent(
chatRoomId = marmotCommitResult.chatRoomId,
userPublicKey = marmotCommitResult.userPublicKey,
welcomeBytes = welcomeBytes,
peerKeyPackageEventId = marmotCommitResult.peerKeyPackageEventId,
createdAt = marmotCommitResult.createdAt,
relays = Relays.DefaultDMRelayList.map { it.url } // TODO: Get localChatRoom relays...
)
}
}

View File

@@ -230,10 +230,17 @@ object MarmotInboundManager {
)
if (mlsBytes == null) {
// Expected when this kind:445 was encrypted with an epoch
// key that predates our join (classical MLS forward
// secrecy), or when the sender's epoch has drifted. Not
// an error — callers should log at DEBUG.
// Expected when the sender's epoch has drifted, or when a
// key that predates our join is what this was encrypted
// with (classical MLS forward secrecy). Not an error —
// callers should log at DEBUG.
//
// The second of those is now rare rather than routine:
// `NostrDao.indexMarmotGroupEvent` holds back anything the
// group published before this device joined, so what
// reaches here from before our epoch is only what sits on
// the boundary — see ChatRoom.predatesMembership. A room
// full of these is a sign that gate is not being applied.
GroupEventResult.UndecryptableOuterLayer(
localChatRoom.chatRoom.id,
retainedEpochCount = retainedExporterSecrets(localChatRoom.chatRoom.id).size,
@@ -656,9 +663,11 @@ object MarmotInboundManager {
*
* Returns null when neither the current epoch key nor any retained key
* decrypts. This happens normally for commits/application messages from
* epochs that predate our join (we never held those keys), so callers
* should treat null as an expected "nothing to do here" outcome and log
* at DEBUG, not as an error.
* epochs we never held the keys for, so callers should treat null as an
* expected "nothing to do here" outcome and log at DEBUG, not as an error.
* Most of that class of event no longer gets this far: what predates this
* device's join is held back before any of it is attempted -- see
* `ChatRoom.predatesMembership`.
*/
private fun tryDecryptOuterLayer(
mlsGroup: MlsGroup,

View File

@@ -36,6 +36,11 @@ object MarmotReindexSweep {
/**
* @param stored how many group events the room holds in total, for the report.
* @param predatingMembership how many of [stored] the caller held back because
* they were published before this device joined, for the report. Carried
* through rather than worked out here for the same reason [stored] is: the
* sweep decides how many times to go round, not what is worth going round
* for.
* @param unresolved those with nothing to show for them, in the order to replay
* them. Anything already read must be left out: a replay is only ever allowed
* to touch events it cannot make worse.
@@ -48,6 +53,7 @@ object MarmotReindexSweep {
*/
suspend fun <T> run(
stored: Int,
predatingMembership: Int = 0,
unresolved: List<T>,
maxPasses: Int = DEFAULT_MAX_PASSES,
replay: suspend (T) -> Unit,
@@ -85,6 +91,7 @@ object MarmotReindexSweep {
return MarmotReindexReport(
stored = stored,
predatingMembership = predatingMembership,
unresolved = unresolved.size,
recovered = recovered,
failed = remaining.size,

View File

@@ -632,13 +632,27 @@ private fun ReindexMarmotGroupEventsButton(
when (reindexState) {
is ChatRoomDetailViewModel.ReindexState.Done -> {
val report = reindexState.report
// What was published before this member joined is named rather
// than counted as read. Their epoch keys were never on this
// device, so "all read" would be a claim about messages nobody
// here can open -- and a room that is mostly older than the
// member is the ordinary case for anyone invited into one.
val beforeJoining =
if (report.predatingMembership > 0) {
" · ${report.predatingMembership} from before you joined"
} else {
""
}
Text(
text = when {
report.isNoOp -> "Nothing to reindex · ${report.stored} event(s) all read"
report.isNoOp ->
"Nothing to reindex · ${report.stored - report.predatingMembership} " +
"event(s) all read$beforeJoining"
report.recovered > 0 && report.failed > 0 ->
"Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable"
report.recovered > 0 -> "Recovered ${report.recovered} of ${report.unresolved} event(s)"
else -> "${report.failed} event(s) still unreadable"
"Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable$beforeJoining"
report.recovered > 0 ->
"Recovered ${report.recovered} of ${report.unresolved} event(s)$beforeJoining"
else -> "${report.failed} event(s) still unreadable$beforeJoining"
},
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center

View File

@@ -424,6 +424,24 @@ class ChatMessageListViewModel(
return@items
}
// Inviting somebody is nobody's words either, and
// for a while it was not in the room at all: the
// line was written when the Welcome went out, which
// on the deferred path is a relay round trip away
// and may never happen. Passed as answered and
// settled for the same reason the chronicle's are
// -- these report rather than ask, so they stay in
// the quiet tint and offer nothing to review.
if (localChatMessage.chatMessage.messageType in ChatMessage.MEMBERSHIP_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
@@ -745,6 +763,13 @@ private fun RitualNotice(
ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download
// An invite made and an invite sent are two separate steps on the deferred
// path, so they get separate icons -- the whole reason both lines exist is
// to be able to see that the first happened and the second did not.
ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd
ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload
ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline
else -> Icons.Default.PanTool
}
@@ -763,7 +788,8 @@ private fun RitualNotice(
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ->
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED ->
MaterialTheme.colorScheme.error
isRequest -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant