Merge branch 'mantra' into claude/marmot-group-message-queue-1ecaed
This commit is contained in:
@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 14,
|
||||
version = 16,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -255,6 +255,20 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// meanings would have met. Room can rename a column and cannot rewrite the
|
||||
// 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 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 = 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()
|
||||
|
||||
@@ -9,18 +9,61 @@ import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* The chat-list queries carry the room's newest line with them.
|
||||
*
|
||||
* A correlated subquery rather than a `GROUP BY chatRoomId` with `MAX(createdAt)`: an
|
||||
* [press.mantra.compose.database.model.ChatMessage] timestamp is stored to the second,
|
||||
* so a burst of ritual lines written in the same second ties, and the aggregate would
|
||||
* pick one of them arbitrarily. Falling back to `id DESC` breaks the tie on the order
|
||||
* the rows were actually written, which is the order the transcript shows them in.
|
||||
*
|
||||
* A room with nothing said in it sorts on when it was created, so a freshly made room
|
||||
* appears at the top where the user left it rather than at the bottom under everything.
|
||||
*/
|
||||
private const val CHAT_ROOM_WITH_LAST_MESSAGE =
|
||||
"SELECT ChatRoom.*, " +
|
||||
"lastMessage.senderPublicKey AS lastMessage_senderPublicKey, " +
|
||||
"lastMessage.isUserMessage AS lastMessage_isUserMessage, " +
|
||||
"lastMessage.content AS lastMessage_content, " +
|
||||
"lastMessage.messageType AS lastMessage_messageType, " +
|
||||
"lastMessage.directMessageRecipientPublicKey AS lastMessage_directMessageRecipientPublicKey, " +
|
||||
"lastMessage.createdAt AS lastMessage_createdAt " +
|
||||
"FROM ChatRoom " +
|
||||
"LEFT JOIN ChatMessage AS lastMessage ON lastMessage.id = (" +
|
||||
"SELECT id FROM ChatMessage " +
|
||||
"WHERE chatRoomId = ChatRoom.id AND deletedAt IS NULL " +
|
||||
"ORDER BY createdAt DESC, id DESC LIMIT 1" +
|
||||
") "
|
||||
|
||||
private const val ORDER_BY_LAST_ACTIVITY =
|
||||
"ORDER BY COALESCE(lastMessage.createdAt, ChatRoom.createdAt) DESC, ChatRoom.createdAt DESC"
|
||||
|
||||
@Dao
|
||||
interface ChatRoomDao {
|
||||
@Transaction
|
||||
@Query("SELECT * FROM ChatRoom WHERE id = :id AND deletedAt IS NULL")
|
||||
@Query(CHAT_ROOM_WITH_LAST_MESSAGE + "WHERE ChatRoom.id = :id AND ChatRoom.deletedAt IS NULL")
|
||||
suspend fun findChatRoomById(id: String): LocalChatRoom?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey AND deletedAt IS NULL")
|
||||
@Query(
|
||||
CHAT_ROOM_WITH_LAST_MESSAGE +
|
||||
"WHERE ChatRoom.userPublicKey = :userPublicKey AND ChatRoom.deletedAt IS NULL " +
|
||||
ORDER_BY_LAST_ACTIVITY
|
||||
)
|
||||
suspend fun getChatRoomListByUserPublicKey(userPublicKey: String): List<LocalChatRoom>
|
||||
|
||||
/**
|
||||
* Re-emits when a message lands as well as when a room changes -- the query reads
|
||||
* ChatMessage, so Room invalidates it on both, which is what keeps a row's preview
|
||||
* and its place in the order current without the list asking.
|
||||
*/
|
||||
@Transaction
|
||||
@Query("SELECT * FROM ChatRoom WHERE userPublicKey = :userPublicKey AND deletedAt IS NULL")
|
||||
@Query(
|
||||
CHAT_ROOM_WITH_LAST_MESSAGE +
|
||||
"WHERE ChatRoom.userPublicKey = :userPublicKey AND ChatRoom.deletedAt IS NULL " +
|
||||
ORDER_BY_LAST_ACTIVITY
|
||||
)
|
||||
fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow<List<LocalChatRoom>>
|
||||
|
||||
@Upsert
|
||||
@@ -29,4 +72,4 @@ interface ChatRoomDao {
|
||||
@Delete
|
||||
suspend fun delete(chatRoom: ChatRoom)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.name
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
@@ -69,6 +70,11 @@ import kotlin.time.Instant
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
// The chat list reads the newest line of every room the user is in, once per
|
||||
// room, and re-reads all of them each time a message lands anywhere. Without
|
||||
// this that is a scan of every message this device holds, per room, per
|
||||
// arrival -- work that grows with the whole history rather than with the room.
|
||||
indices = [Index("chatRoomId")],
|
||||
)
|
||||
data class ChatMessage(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@@ -357,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(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package press.mantra.compose.database.model.intermdiate
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The newest line in a room, as much of it as a chat list row can show.
|
||||
*
|
||||
* Not the [press.mantra.compose.database.model.ChatMessage] itself: embedding the entity
|
||||
* would mean aliasing thirty-odd columns onto every chat-room query, and colliding with
|
||||
* the room's own `id` and its four timestamps on the way. These six are everything a
|
||||
* one-line preview and the clock beside it are written from.
|
||||
*
|
||||
* Read back as null for a room nothing has been said in yet, which is a different thing
|
||||
* from a room whose last line has no words -- a private message this device cannot open
|
||||
* is exactly that, and says so rather than showing an empty row.
|
||||
*/
|
||||
data class ChatRoomLastMessage(
|
||||
val senderPublicKey: HexKey,
|
||||
val isUserMessage: Boolean,
|
||||
val content: String,
|
||||
val messageType: String,
|
||||
val directMessageRecipientPublicKey: HexKey?,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
@@ -1,15 +1,20 @@
|
||||
package press.mantra.compose.database.model.intermdiate
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Relation
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.extensions.memberName
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
data class LocalChatRoom(
|
||||
@Embedded val chatRoom: ChatRoom,
|
||||
@@ -20,18 +25,33 @@ data class LocalChatRoom(
|
||||
entityColumns = ["chatRoomId"],
|
||||
)
|
||||
val localParticipants: List<LocalParticipant> = emptyList(),
|
||||
|
||||
/**
|
||||
* The room's newest line, or null for a room nothing has been said in yet.
|
||||
*
|
||||
* Only the chat-list queries fill this in; every other way of getting a room
|
||||
* leaves it null rather than paying for a join no screen reads. So a null here
|
||||
* means "not asked for" as often as it means "nothing said", which is why
|
||||
* nothing hangs a decision on it beyond what to draw.
|
||||
*/
|
||||
@Embedded(prefix = "lastMessage_")
|
||||
val lastChatMessage: ChatRoomLastMessage? = null,
|
||||
) {
|
||||
@Composable
|
||||
fun RenderChatRoomTitleText() {
|
||||
if (chatRoom.subject != null) {
|
||||
Text(
|
||||
text = chatRoom.subject,
|
||||
color = ProfileColor.fromPublicKey(chatRoom.id)
|
||||
color = ProfileColor.fromPublicKey(chatRoom.id),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
if (localParticipants.size == 1) {
|
||||
Text(
|
||||
text = "Note to Self (${localParticipants.first().profile?.humanReadableNameOrPubkey()})"
|
||||
text = "Note to Self (${localParticipants.first().profile?.humanReadableNameOrPubkey()})",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
// Remove the active user publicKey from participants...
|
||||
@@ -50,8 +70,83 @@ data class LocalChatRoom(
|
||||
append(participantName)
|
||||
}
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line saying what was last said here, under the room's name in the chat list.
|
||||
*
|
||||
* A quieter echo of how the transcript renders that same line, so the two never
|
||||
* disagree about what a room's newest activity was.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderChatRoomLastMessageText() {
|
||||
Text(
|
||||
text = lastChatMessagePreviewText() ?: "No messages yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The room's newest line as a single line of text, or null if there is none.
|
||||
*
|
||||
* Follows the transcript's own dispatch, for the same reasons it does:
|
||||
*
|
||||
* - 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.
|
||||
* - anything else is somebody's words, prefixed with who said them -- except in a
|
||||
* two-person room, where the only other name is already the row's title.
|
||||
*/
|
||||
fun lastChatMessagePreviewText(): String? {
|
||||
val lastChatMessage = lastChatMessage ?: return null
|
||||
|
||||
if (lastChatMessage.messageType in ChatMessage.DKG_TYPES ||
|
||||
lastChatMessage.messageType in ChatMessage.FROST_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
|
||||
|
||||
return if (isAuthored) {
|
||||
"${nameFor(lastChatMessage.senderPublicKey)} ${lastChatMessage.content}"
|
||||
} else {
|
||||
lastChatMessage.content
|
||||
}
|
||||
}
|
||||
|
||||
if (lastChatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE &&
|
||||
lastChatMessage.content.isBlank()
|
||||
) {
|
||||
return "${nameFor(lastChatMessage.senderPublicKey)} sent a private message to " +
|
||||
nameFor(lastChatMessage.directMessageRecipientPublicKey)
|
||||
}
|
||||
|
||||
val isGroup = localParticipants.count {
|
||||
it.participant.participantPublicKey != chatRoom.userPublicKey
|
||||
} > 1
|
||||
|
||||
return when {
|
||||
lastChatMessage.isUserMessage -> "You: ${lastChatMessage.content}"
|
||||
isGroup -> "${nameFor(lastChatMessage.senderPublicKey)}: ${lastChatMessage.content}"
|
||||
else -> lastChatMessage.content
|
||||
}
|
||||
}
|
||||
|
||||
/** "someone" for a message with no recipient -- which no direct message has. */
|
||||
private fun nameFor(publicKey: HexKey?): String =
|
||||
publicKey?.memberName(localParticipants, chatRoom.userPublicKey) ?: "someone"
|
||||
}
|
||||
|
||||
@@ -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...
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package press.mantra.compose.extensions
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.format
|
||||
import kotlinx.datetime.format.DayOfWeekNames
|
||||
import kotlinx.datetime.format.MonthNames
|
||||
import kotlinx.datetime.format.char
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
fun Instant.toFormattedTimeAndDateString(): String {
|
||||
@@ -26,4 +30,59 @@ fun Instant.toFormattedTimeAndDateString(): String {
|
||||
year()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A chat list's clock: the least that still says which line is newer.
|
||||
*
|
||||
* The row already carries the message itself, so the timestamp only has to place it —
|
||||
* and a full date on today's message crowds out the words it belongs to. Coarser the
|
||||
* further back it goes, and never coarser than the reader can resolve: a weekday is
|
||||
* unambiguous for six days and no longer, and a bare day and month for a year.
|
||||
*
|
||||
* A timestamp ahead of [now] gets a date rather than a time. Relay clocks disagree and
|
||||
* an event can arrive stamped in the future; rendering that as "14:05" would put it in
|
||||
* a today it does not belong to.
|
||||
*/
|
||||
fun Instant.toChatListTimestampString(now: Instant = Clock.System.now()): String {
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val dateTime = toLocalDateTime(timeZone)
|
||||
val today = now.toLocalDateTime(timeZone).date
|
||||
val daysAgo = dateTime.date.daysUntil(today)
|
||||
|
||||
return when {
|
||||
daysAgo == 0 -> dateTime.format(
|
||||
LocalDateTime.Format {
|
||||
hour()
|
||||
char(':')
|
||||
minute()
|
||||
}
|
||||
)
|
||||
|
||||
daysAgo == 1 -> "Yesterday"
|
||||
|
||||
daysAgo in 2..6 -> dateTime.date.format(
|
||||
LocalDate.Format {
|
||||
dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED)
|
||||
}
|
||||
)
|
||||
|
||||
dateTime.year == today.year -> dateTime.date.format(
|
||||
LocalDate.Format {
|
||||
day()
|
||||
char(' ')
|
||||
monthName(MonthNames.ENGLISH_ABBREVIATED)
|
||||
}
|
||||
)
|
||||
|
||||
else -> dateTime.date.format(
|
||||
LocalDate.Format {
|
||||
day()
|
||||
char(' ')
|
||||
monthName(MonthNames.ENGLISH_ABBREVIATED)
|
||||
char(' ')
|
||||
year()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -27,6 +29,7 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import press.mantra.compose.database.model.NegentropySynchronizeRequest
|
||||
import press.mantra.compose.database.model.types.SynchronizationFilter
|
||||
import press.mantra.compose.extensions.toChatListTimestampString
|
||||
import press.mantra.compose.nostr.Nip17Filters
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
@@ -186,11 +189,36 @@ class ChatRoomListViewModel(
|
||||
)
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
localChatRoom.RenderChatRoomTitleText()
|
||||
// Both lines are clipped to one, so the name
|
||||
// and the preview keep their width whatever
|
||||
// was said -- a long message must not push
|
||||
// the clock off the row.
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
localChatRoom.RenderChatRoomTitleText()
|
||||
localChatRoom.RenderChatRoomLastMessageText()
|
||||
}
|
||||
|
||||
// Absent rather than blank for a room with
|
||||
// nothing in it: there is no time to show,
|
||||
// and the room's own creation -- which is
|
||||
// what it sorts on -- is not one the user
|
||||
// has any reason to read here.
|
||||
localChatRoom.lastChatMessage?.let { lastChatMessage ->
|
||||
Text(
|
||||
text = lastChatMessage.createdAt.toChatListTimestampString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user