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