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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Which of a group's kind:445 events are this device's to read at all.
|
||||
*
|
||||
* MLS hands a joiner the key schedule from their own epoch forward and nothing
|
||||
* before it, but the relay hands them the whole room. Everything the group
|
||||
* published earlier still syncs down and is still permanently unreadable, so a
|
||||
* member invited into a busy room opened it on a run of "Undecryptable Message"
|
||||
* above the conversation they were invited to.
|
||||
*
|
||||
* The epoch an event was encrypted under is inside the layer that will not
|
||||
* decrypt, so the only thing left to ask is when it was published. That makes
|
||||
* the boundary a judgement call rather than a fact, and the direction it errs in
|
||||
* is what these pin down: an unreadable event kept costs one refused decrypt, a
|
||||
* readable event discarded is a message the member never sees.
|
||||
*/
|
||||
class ChatRoomMembershipWindowTest {
|
||||
|
||||
private val roomId = "a".repeat(64)
|
||||
private val user = "b".repeat(64)
|
||||
|
||||
private fun room(
|
||||
joinedGroupAt: Instant?,
|
||||
createdAt: Instant = Instant.fromEpochSeconds(5_000),
|
||||
) = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
joinedGroupAt = joinedGroupAt,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a message from before the welcome is not this devices to read`() {
|
||||
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(1_999)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message from after the welcome is`() {
|
||||
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_001)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The boundary, and the reason it is drawn strictly.
|
||||
*
|
||||
* Nostr stamps `created_at` in whole seconds, so the second the Welcome was
|
||||
* minted holds both the commit that added us -- the last act of the epoch
|
||||
* before ours, unreadable by construction -- and any message another member
|
||||
* sent the instant they applied it. Only one of those two can be had, and a
|
||||
* message is worth more than a spared decrypt.
|
||||
*/
|
||||
@Test
|
||||
fun `a message from the very second of the welcome is kept`() {
|
||||
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
|
||||
|
||||
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_000)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A room joined before the column existed. `createdAt` is when this device
|
||||
* wrote the row down, which for a joiner is the Welcome it wrote it from --
|
||||
* the same answer every path that sets `joinedGroupAt` would have written.
|
||||
*/
|
||||
@Test
|
||||
fun `a room with no recorded join falls back to when it was written down`() {
|
||||
val chatRoom = room(joinedGroupAt = null, createdAt = Instant.fromEpochSeconds(5_000))
|
||||
|
||||
assertEquals(Instant.fromEpochSeconds(5_000), chatRoom.memberSince)
|
||||
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(4_999)))
|
||||
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(5_000)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A device that joined a room it had already heard of -- its own invite
|
||||
* gift wrap arrived out of order, say, and the row was written before the
|
||||
* Welcome was processed. The recorded join is the group's own account of
|
||||
* when our epoch began and beats this device's account of when it started
|
||||
* keeping notes.
|
||||
*/
|
||||
@Test
|
||||
fun `a recorded join wins over when the row was written`() {
|
||||
val chatRoom = room(
|
||||
joinedGroupAt = Instant.fromEpochSeconds(9_000),
|
||||
createdAt = Instant.fromEpochSeconds(5_000),
|
||||
)
|
||||
|
||||
assertEquals(Instant.fromEpochSeconds(9_000), chatRoom.memberSince)
|
||||
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(6_000)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package press.mantra.compose.database.model.intermdiate
|
||||
|
||||
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.database.model.Profile
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The one line under a room's name in the chat list.
|
||||
*
|
||||
* It is a summary of somebody's message shown next to their name, which is the shape
|
||||
* every way of getting this wrong takes: attributing a ceremony milestone to the member
|
||||
* who happened to trigger it, showing an empty row where a private message this device
|
||||
* cannot open should say so, or putting a name in front of a line in a two-person room
|
||||
* where the name is already the title.
|
||||
*/
|
||||
class ChatRoomLastMessagePreviewTest {
|
||||
|
||||
private val user = "a".repeat(64)
|
||||
private val alice = "b".repeat(64)
|
||||
private val bob = "c".repeat(64)
|
||||
|
||||
private fun room(
|
||||
participants: List<String>,
|
||||
lastChatMessage: ChatRoomLastMessage?,
|
||||
) = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "11".repeat(32),
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
),
|
||||
localParticipants = (participants + user).map { publicKey ->
|
||||
LocalParticipant(
|
||||
participant = Participant(
|
||||
participantPublicKey = publicKey,
|
||||
chatRoomId = "11".repeat(32),
|
||||
relayHint = null,
|
||||
),
|
||||
profile = Profile(
|
||||
publicKey = publicKey,
|
||||
userName = when (publicKey) {
|
||||
alice -> "Alice"
|
||||
bob -> "Bob"
|
||||
else -> "Me"
|
||||
},
|
||||
nostrEventId = "d".repeat(64),
|
||||
),
|
||||
)
|
||||
},
|
||||
lastChatMessage = lastChatMessage,
|
||||
)
|
||||
|
||||
private fun message(
|
||||
content: String = "hello",
|
||||
sender: String = alice,
|
||||
messageType: String = "message",
|
||||
directMessageRecipientPublicKey: String? = null,
|
||||
) = ChatRoomLastMessage(
|
||||
senderPublicKey = sender,
|
||||
isUserMessage = sender == user,
|
||||
content = content,
|
||||
messageType = messageType,
|
||||
directMessageRecipientPublicKey = directMessageRecipientPublicKey,
|
||||
createdAt = Instant.fromEpochSeconds(1_000),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a room with nothing said in it has no preview`() {
|
||||
assertNull(room(listOf(alice), lastChatMessage = null).lastChatMessagePreviewText())
|
||||
}
|
||||
|
||||
/** The other party's name is the row's title already; repeating it says nothing. */
|
||||
@Test
|
||||
fun `a two-person room shows the words alone`() {
|
||||
assertEquals(
|
||||
"hello",
|
||||
room(listOf(alice), message(content = "hello")).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group names who spoke`() {
|
||||
assertEquals(
|
||||
"Alice: hello",
|
||||
room(listOf(alice, bob), message(content = "hello")).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
/** True in both room shapes: the title never names the reader. */
|
||||
@Test
|
||||
fun `the reader's own message is prefixed either way`() {
|
||||
assertEquals(
|
||||
"You: hello",
|
||||
room(listOf(alice), message(content = "hello", sender = user)).lastChatMessagePreviewText()
|
||||
)
|
||||
assertEquals(
|
||||
"You: hello",
|
||||
room(listOf(alice, bob), message(content = "hello", sender = user)).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ritual content is written as a predicate for the actor's name, so an authored line
|
||||
* needs it and reads as nonsense without it.
|
||||
*/
|
||||
@Test
|
||||
fun `an authored ritual line reads as its actor doing it`() {
|
||||
assertEquals(
|
||||
"Alice published their share",
|
||||
room(
|
||||
listOf(alice, bob),
|
||||
message(content = "published their share", messageType = ChatMessage.TYPE_DKG_ROUND_1)
|
||||
).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The group ends up with a key; nobody hands it to them. A name in front of this
|
||||
* would read as the member having announced it.
|
||||
*/
|
||||
@Test
|
||||
fun `a ritual line nobody authored stands alone`() {
|
||||
assertEquals(
|
||||
"The group now has a shared key",
|
||||
room(
|
||||
listOf(alice, bob),
|
||||
message(
|
||||
content = "The group now has a shared key",
|
||||
messageType = ChatMessage.TYPE_DKG_COMPLETE
|
||||
)
|
||||
).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
/** A chronicle is not anybody's words either, whichever end of it this device was on. */
|
||||
@Test
|
||||
fun `a chronicle line stands alone`() {
|
||||
assertEquals(
|
||||
"Caught up on 12 items",
|
||||
room(
|
||||
listOf(alice, bob),
|
||||
message(
|
||||
content = "Caught up on 12 items",
|
||||
messageType = ChatMessage.TYPE_CHRONICLE_RECEIVED
|
||||
)
|
||||
).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An invite is nobody's words either, and its content is a whole sentence with the
|
||||
* invitee's name already in it. A prefix here reads as the inviter having said
|
||||
* "Invited Bob to the group" out loud.
|
||||
*/
|
||||
@Test
|
||||
fun `a membership line stands alone`() {
|
||||
ChatMessage.MEMBERSHIP_TYPES.forEach { messageType ->
|
||||
assertEquals(
|
||||
"Invited Bob to the group",
|
||||
room(
|
||||
listOf(alice, bob),
|
||||
message(content = "Invited Bob to the group", messageType = messageType)
|
||||
).lastChatMessagePreviewText(),
|
||||
"$messageType should not be attributed to its sender"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bystanders' copy of a private message has no content. An empty preview would
|
||||
* read as the sender having said nothing, so the line says what the group can in
|
||||
* fact see: that a private message was sent, and to whom.
|
||||
*/
|
||||
@Test
|
||||
fun `a private message this device cannot open says so`() {
|
||||
assertEquals(
|
||||
"Alice sent a private message to Bob",
|
||||
room(
|
||||
listOf(alice, bob),
|
||||
message(
|
||||
content = "",
|
||||
messageType = ChatMessage.TYPE_DIRECT_MESSAGE,
|
||||
directMessageRecipientPublicKey = bob,
|
||||
)
|
||||
).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
/** A private message this device can read is a message like any other. */
|
||||
@Test
|
||||
fun `a readable private message shows its words`() {
|
||||
assertEquals(
|
||||
"Alice: just between us",
|
||||
room(
|
||||
listOf(alice, bob),
|
||||
message(
|
||||
content = "just between us",
|
||||
messageType = ChatMessage.TYPE_DIRECT_MESSAGE,
|
||||
directMessageRecipientPublicKey = user,
|
||||
)
|
||||
).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A sender the room has no participant row for -- a member who has since left, or one
|
||||
* whose profile has not arrived. Falls back to a shortened key rather than dropping
|
||||
* the attribution, which would silently reassign the words to nobody.
|
||||
*/
|
||||
@Test
|
||||
fun `an unknown sender is named by their key`() {
|
||||
val stranger = "e".repeat(64)
|
||||
|
||||
assertEquals(
|
||||
"${stranger.take(8)}: hello",
|
||||
room(listOf(alice, bob), message(sender = stranger)).lastChatMessagePreviewText()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package press.mantra.compose.extensions
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The clock on a chat list row. It has one job -- placing a message relative to now --
|
||||
* and each rung is only unambiguous over a bounded span: a bare time means nothing on
|
||||
* a message from last week, a weekday means nothing past six days, and a day and month
|
||||
* mean nothing past a year. Reading a rung too far is the failure mode, and it is silent.
|
||||
*
|
||||
* Every case is anchored to the local day rather than to a fixed instant, because the
|
||||
* boundaries are local midnights and the test would otherwise pass or fail on the
|
||||
* machine's zone.
|
||||
*/
|
||||
class ChatListTimestampTest {
|
||||
|
||||
private val timeZone = TimeZone.currentSystemDefault()
|
||||
|
||||
/** Midday today, local, so no case lands on a boundary by accident. */
|
||||
private val now = Instant.parse("2026-09-06T00:00:00Z")
|
||||
.toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone) + 12.hours
|
||||
|
||||
private fun daysBefore(days: Int) = now - days.days
|
||||
|
||||
@Test
|
||||
fun `today shows the time of day`() {
|
||||
val formatted = daysBefore(0).toChatListTimestampString(now)
|
||||
|
||||
assertEquals(
|
||||
now.toLocalDateTime(timeZone).let {
|
||||
"${it.hour.toString().padStart(2, '0')}:${it.minute.toString().padStart(2, '0')}"
|
||||
},
|
||||
formatted
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday is named rather than dated`() {
|
||||
assertEquals("Yesterday", daysBefore(1).toChatListTimestampString(now))
|
||||
}
|
||||
|
||||
/**
|
||||
* Two through six days. A seventh would be this weekday again, which reads as today.
|
||||
*/
|
||||
@Test
|
||||
fun `the rest of the last week shows a weekday`() {
|
||||
(2..6).forEach { days ->
|
||||
val formatted = daysBefore(days).toChatListTimestampString(now)
|
||||
|
||||
assertEquals(3, formatted.length, "$days days ago should be an abbreviated weekday")
|
||||
assertTrue(
|
||||
formatted.none { it.isDigit() },
|
||||
"$days days ago should be a weekday, not a date: $formatted"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A week out the weekday has stopped meaning anything, so the date takes over. */
|
||||
@Test
|
||||
fun `beyond a week shows a day and month`() {
|
||||
val formatted = daysBefore(7).toChatListTimestampString(now)
|
||||
val date = daysBefore(7).toLocalDateTime(timeZone).date
|
||||
|
||||
assertEquals("${date.day} ${date.month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3)}", formatted)
|
||||
}
|
||||
|
||||
/** Past a year the day and month repeat, so the year has to be said. */
|
||||
@Test
|
||||
fun `a message from another year carries its year`() {
|
||||
val formatted = daysBefore(400).toChatListTimestampString(now)
|
||||
val date = daysBefore(400).toLocalDateTime(timeZone).date
|
||||
|
||||
assertTrue(
|
||||
formatted.endsWith(" ${date.year}"),
|
||||
"a message from ${date.year} should say so: $formatted"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay clocks disagree and an event can arrive stamped ahead of this device. Showing
|
||||
* it as a bare time would file it under a today it does not belong to.
|
||||
*/
|
||||
@Test
|
||||
fun `a timestamp from the future gets a date rather than a time`() {
|
||||
val formatted = (now + 2.days).toChatListTimestampString(now)
|
||||
|
||||
assertTrue(':' !in formatted, "a future timestamp should not read as a time today: $formatted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The chat list's order and its previews both come out of one query, so a single wrong
|
||||
* join reads as two unrelated bugs: rooms sorted by nothing anybody can see, and a row
|
||||
* quoting a message that is not the newest one in it.
|
||||
*
|
||||
* The parts that can only be checked against a real SQLite are here -- which row the
|
||||
* correlated subquery picks, and how the ordering treats a room nothing has been said
|
||||
* in. What the picked row is then rendered as is [press.mantra.compose.database.model.intermdiate.LocalChatRoom]'s
|
||||
* own business and is tested against it directly.
|
||||
*/
|
||||
class ChatRoomDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = "a".repeat(64)
|
||||
private val quiet = "11".repeat(32)
|
||||
private val busy = "22".repeat(32)
|
||||
private val stale = "33".repeat(32)
|
||||
|
||||
/** Rooms created oldest-first, so creation order and activity order disagree. */
|
||||
private suspend fun seedRooms() {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
|
||||
listOf(stale to 100L, busy to 200L, quiet to 300L).forEach { (id, createdAt) ->
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = id,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
createdAt = Instant.fromEpochSeconds(createdAt),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun line(
|
||||
chatRoomId: String,
|
||||
content: String = "a line",
|
||||
createdAt: Long,
|
||||
deletedAt: Long? = null,
|
||||
): Long = db.chatMessageDao().upsert(
|
||||
ChatMessage(
|
||||
content = content,
|
||||
chatRoomId = chatRoomId,
|
||||
senderPublicKey = user,
|
||||
isUserMessage = true,
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
createdAt = Instant.fromEpochSeconds(createdAt),
|
||||
deletedAt = deletedAt?.let { Instant.fromEpochSeconds(it) },
|
||||
)
|
||||
)
|
||||
|
||||
private suspend fun rooms() = db.chatRoomDao().getChatRoomListByUserPublicKey(user)
|
||||
|
||||
@Test
|
||||
fun `rooms are ordered by their newest message`() = runBlocking {
|
||||
seedRooms()
|
||||
line(stale, createdAt = 1_000)
|
||||
line(busy, createdAt = 9_000)
|
||||
line(quiet, createdAt = 5_000)
|
||||
|
||||
assertEquals(listOf(busy, quiet, stale), rooms().map { it.chatRoom.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* A room nothing has been said in still has to land somewhere, and the only time it
|
||||
* has is its own. Sorting it last regardless would bury a room the user just made
|
||||
* under every conversation they have ever had.
|
||||
*/
|
||||
@Test
|
||||
fun `a room with no messages sorts on when it was created`() = runBlocking {
|
||||
seedRooms()
|
||||
line(stale, createdAt = 50)
|
||||
line(busy, createdAt = 250)
|
||||
|
||||
// quiet was created at 300, so it outranks both.
|
||||
assertEquals(listOf(quiet, busy, stale), rooms().map { it.chatRoom.id })
|
||||
assertNull(rooms().first().lastChatMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the newest line in a room is the one carried`() = runBlocking {
|
||||
seedRooms()
|
||||
line(busy, content = "older", createdAt = 1_000)
|
||||
line(busy, content = "newest", createdAt = 2_000)
|
||||
line(quiet, content = "someone else's room", createdAt = 3_000)
|
||||
|
||||
val lastChatMessage = rooms().first { it.chatRoom.id == busy }.lastChatMessage
|
||||
|
||||
assertNotNull(lastChatMessage)
|
||||
assertEquals("newest", lastChatMessage.content)
|
||||
assertEquals(Instant.fromEpochSeconds(2_000), lastChatMessage.createdAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamps are stored to the second, so a burst written in one second ties. The
|
||||
* transcript orders those on their row ids, and the preview has to agree with it --
|
||||
* a room whose last two lines arrived together must not quote whichever one SQLite
|
||||
* happened to reach first.
|
||||
*/
|
||||
@Test
|
||||
fun `lines written in the same second break the tie on write order`() = runBlocking {
|
||||
seedRooms()
|
||||
line(busy, content = "first", createdAt = 1_000)
|
||||
line(busy, content = "second", createdAt = 1_000)
|
||||
line(busy, content = "third", createdAt = 1_000)
|
||||
|
||||
assertEquals("third", rooms().first { it.chatRoom.id == busy }.lastChatMessage?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted line is not the room's last message`() = runBlocking {
|
||||
seedRooms()
|
||||
line(busy, content = "kept", createdAt = 1_000)
|
||||
line(busy, content = "deleted", createdAt = 2_000, deletedAt = 2_500)
|
||||
|
||||
assertEquals("kept", rooms().first { it.chatRoom.id == busy }.lastChatMessage?.content)
|
||||
}
|
||||
|
||||
/** One room's traffic must not become another room's preview. */
|
||||
@Test
|
||||
fun `the last message is scoped to its own room`() = runBlocking {
|
||||
seedRooms()
|
||||
line(busy, content = "in the busy room", createdAt = 9_000)
|
||||
|
||||
assertEquals("in the busy room", rooms().first { it.chatRoom.id == busy }.lastChatMessage?.content)
|
||||
assertNull(rooms().first { it.chatRoom.id == quiet }.lastChatMessage)
|
||||
assertNull(rooms().first { it.chatRoom.id == stale }.lastChatMessage)
|
||||
}
|
||||
|
||||
/** The single-room lookup reads the same shape, so a detail screen sees what the list did. */
|
||||
@Test
|
||||
fun `finding one room carries its last message too`() = runBlocking {
|
||||
seedRooms()
|
||||
line(busy, content = "newest", createdAt = 2_000)
|
||||
|
||||
assertEquals("newest", db.chatRoomDao().findChatRoomById(busy)?.lastChatMessage?.content)
|
||||
assertNull(db.chatRoomDao().findChatRoomById(quiet)?.lastChatMessage)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MarmotKeyPackage
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
@@ -51,25 +52,36 @@ class MarmotOutboundDaoJvmTest {
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val peer = KeyPair().pubKey.toHexKey()
|
||||
private val secondPeer = KeyPair().pubKey.toHexKey()
|
||||
private val roomId = "b".repeat(64)
|
||||
|
||||
/**
|
||||
* A room with `mlsGroupState = null` -- exactly the shape a room restored from an inbound
|
||||
* gift wrap has, which is the case the guard exists for.
|
||||
* A member the database knows by name. Every invitee needs one --
|
||||
* Participant.participantPublicKey is a foreign key onto Profile -- and the membership
|
||||
* lines read the name back out of it.
|
||||
*/
|
||||
private suspend fun seedStatelessRoom(): LocalChatRoom {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
private suspend fun seedProfile(publicKey: String, name: String, nostrEventId: String) {
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = user,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A room with `mlsGroupState = null` -- exactly the shape a room restored from an inbound
|
||||
* gift wrap has, which is the case the guard exists for.
|
||||
*/
|
||||
private suspend fun seedStatelessRoom(): LocalChatRoom {
|
||||
seedProfile(user, "user", "c".repeat(64))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = user,
|
||||
@@ -100,18 +112,7 @@ class MarmotOutboundDaoJvmTest {
|
||||
*/
|
||||
private suspend fun seedMlsRoom(): LocalChatRoom {
|
||||
val stateless = seedStatelessRoom()
|
||||
val peerEventId = "e".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = peerEventId,
|
||||
pubKey = peer,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = peer, userName = "peer", nostrEventId = peerEventId))
|
||||
seedProfile(peer, "peer", "e".repeat(64))
|
||||
val mlsGroup = MlsGroup.create(
|
||||
identity = user.hexToByteArray(),
|
||||
initialExtensions = listOf(
|
||||
@@ -265,4 +266,110 @@ class MarmotOutboundDaoJvmTest {
|
||||
assertEquals(1, retained.size, "the pre-commit epoch was not retained")
|
||||
assertEquals(0L, retained.single().epoch, "a freshly created group is at epoch 0")
|
||||
}
|
||||
|
||||
// ---- What the room is told about it -----------------------------------
|
||||
|
||||
private suspend fun transcript() = db.chatMessageDao().getChatMessagesByChatRoomId(roomId)
|
||||
.map { it.chatMessage }
|
||||
|
||||
/**
|
||||
* The invite has to reach the transcript, and as a membership line rather than a chat
|
||||
* bubble. It used to be written by `deliveryWelcome` -- which meant it depended on the
|
||||
* invitee's key package and profile both being found, and neither is here, so the room
|
||||
* was told nothing at all about an invite that succeeded.
|
||||
*/
|
||||
@Test
|
||||
fun `inviting a member puts a line in the room`() = runBlocking {
|
||||
val localChatRoom = seedMlsRoom()
|
||||
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
|
||||
val line = transcript().singleOrNull { it.messageType == ChatMessage.TYPE_MEMBER_INVITED }
|
||||
assertNotNull(line, "the invite left no line in the room")
|
||||
assertTrue(line.content.contains("peer"), "the line does not name the invitee: ${line.content}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the line exists for. A group that already has members defers its Welcome until
|
||||
* a relay acknowledges the commit -- see docs/marmot-membership.md -- so an invite made
|
||||
* here is on nothing but a promise, and until the line moved to invite time the room said
|
||||
* nothing whatsoever in the meantime and nothing ever if the ack never came.
|
||||
*/
|
||||
@Test
|
||||
fun `an invite into an established group is in the room before its welcome goes out`() = runBlocking {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = seedMlsRoom(),
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
seedProfile(secondPeer, "second", "f".repeat(64))
|
||||
|
||||
// Re-read: the first invite advanced the epoch and persisted new state.
|
||||
val established = assertNotNull(db.chatRoomDao().findChatRoomById(roomId))
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = established,
|
||||
peerPublicKey = secondPeer,
|
||||
peerKeyPackage = marmotKeyPackageFor(secondPeer),
|
||||
)
|
||||
|
||||
val invited = transcript().filter { it.messageType == ChatMessage.TYPE_MEMBER_INVITED }
|
||||
assertEquals(2, invited.size, "the deferred invite left no line in the room")
|
||||
assertTrue(
|
||||
invited.any { it.content.contains("second") },
|
||||
"the second invitee is not named: ${invited.map { it.content }}",
|
||||
)
|
||||
|
||||
// Nothing has acknowledged the commit, so the Welcome has not gone out and nothing
|
||||
// may claim it has. That absence is what makes a stuck invite visible.
|
||||
assertTrue(
|
||||
transcript().none { it.messageType == ChatMessage.TYPE_MEMBER_INVITE_SENT },
|
||||
"a welcome was reported sent before any relay acknowledged the commit",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership lines are not somebody's words, and the transcript decides that from the
|
||||
* type alone. One missing from [ChatMessage.MEMBERSHIP_TYPES] renders as a chat bubble,
|
||||
* looking exactly like the inviter having said "Invited peer to the group".
|
||||
*/
|
||||
@Test
|
||||
fun `the invite line is a membership line and not a bubble`() = runBlocking {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = seedMlsRoom(),
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = marmotKeyPackageFor(peer),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
transcript().all { it.messageType in ChatMessage.MEMBERSHIP_TYPES },
|
||||
"an invite wrote something the transcript would render as a bubble: " +
|
||||
transcript().map { it.messageType },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused invite must not leave the room claiming one was made. The line is written
|
||||
* inside the caller's transaction precisely so that it goes down with everything else the
|
||||
* refused invite touched -- what tells the user instead is
|
||||
* `DatabaseChatRepository.inviteMember`, from outside it.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused invite leaves no claim that one was made`() = runBlocking {
|
||||
runCatching {
|
||||
db.marmotOutboundDao().inviteMemberToChatRoom(
|
||||
localChatRoom = seedStatelessRoom(),
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
transcript().none { it.messageType == ChatMessage.TYPE_MEMBER_INVITED },
|
||||
"the room was told about an invite that was refused",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What a room does with the kind:445 events the group published before this
|
||||
* device was in it.
|
||||
*
|
||||
* They arrive whatever anyone wants: MLS gives a joiner the key schedule from
|
||||
* their own epoch forward, the relay gives them the whole room, and negentropy
|
||||
* syncs the lot down on first open. Every one of those events is permanently
|
||||
* unreadable, and reading them anyway is what put a run of "Undecryptable
|
||||
* Message" above the conversation a member had just been invited to.
|
||||
*
|
||||
* Asserted at the DAO because the decision is only worth anything where the
|
||||
* backlog is: `indexMarmotGroupEvent` for the events as they land, and
|
||||
* `reindexMarmotGroupEvents` for the replay that would otherwise spend a refused
|
||||
* decrypt on each of them every time a member asks a room to try again.
|
||||
*
|
||||
* The room here has no MLS state, so nothing this replays can be read. That is
|
||||
* the point: it separates events left alone because they predate the join from
|
||||
* events tried and failed, which is the whole distinction under test.
|
||||
*/
|
||||
class MarmotPreJoinIndexingJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val keyPair = KeyPair()
|
||||
private val user = keyPair.pubKey.toHexKey()
|
||||
private val roomId = "a".repeat(64)
|
||||
private val joinedAt = Instant.fromEpochSeconds(5_000)
|
||||
|
||||
private suspend fun seedRoom(joinedGroupAt: Instant? = joinedAt) {
|
||||
val profileEventId = "f".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = profileEventId,
|
||||
pubKey = user,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = user, userName = "member", nostrEventId = profileEventId)
|
||||
)
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = user,
|
||||
subject = null,
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
joinedGroupAt = joinedGroupAt,
|
||||
createdAt = joinedGroupAt ?: joinedAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** One of the group's kind:445 events, stored the way a sync stores it. */
|
||||
private suspend fun seedGroupEvent(id: String, createdAt: Instant): String {
|
||||
val eventId = id.padEnd(64, '0')
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = eventId,
|
||||
pubKey = "b".repeat(64),
|
||||
kind = 445,
|
||||
tags = arrayOf(arrayOf("h", roomId)),
|
||||
content = "ciphertext",
|
||||
sig = "0".repeat(128),
|
||||
createdAt = createdAt,
|
||||
)
|
||||
)
|
||||
return eventId
|
||||
}
|
||||
|
||||
private suspend fun lineFor(groupEventId: String): ChatMessage? =
|
||||
db.chatMessageDao().getChatMessagesByMarmotGroupEventId(groupEventId)
|
||||
|
||||
private suspend fun reindex() = db.nostrDao().reindexMarmotGroupEvents(
|
||||
chatRoomId = roomId,
|
||||
activeKeyPair = keyPair,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a message from before the join leaves no line in the transcript`() = runBlocking {
|
||||
seedRoom()
|
||||
val before = seedGroupEvent("1", Instant.fromEpochSeconds(4_000))
|
||||
|
||||
reindex()
|
||||
|
||||
assertNull(
|
||||
lineFor(before),
|
||||
"a message this device never held the epoch key for is not a message it can show",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounting. `stored` counts what the room holds, because it holds it,
|
||||
* and `predatingMembership` says how much of that a replay was never going to
|
||||
* touch; the rest counts only what it did touch. A sweep that reported the
|
||||
* pre-join backlog as failures said a room was broken when it was working
|
||||
* exactly as designed.
|
||||
*/
|
||||
@Test
|
||||
fun `the pre-join backlog is counted apart from what was replayed`() = runBlocking {
|
||||
seedRoom()
|
||||
seedGroupEvent("1", Instant.fromEpochSeconds(3_000))
|
||||
seedGroupEvent("2", Instant.fromEpochSeconds(4_000))
|
||||
|
||||
val report = reindex()
|
||||
|
||||
assertEquals(2, report.stored)
|
||||
assertEquals(2, report.predatingMembership)
|
||||
assertEquals(0, report.unresolved)
|
||||
assertEquals(0, report.failed)
|
||||
assertEquals(0, report.recovered)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half, and the reason this is a cutoff rather than a blanket
|
||||
* refusal to replay. An event from after the join that could not be read is a
|
||||
* message waiting on a commit that has not landed, which is exactly what a
|
||||
* replay exists to pick up.
|
||||
*/
|
||||
@Test
|
||||
fun `an event from after the join is still replayed`() = runBlocking {
|
||||
seedRoom()
|
||||
seedGroupEvent("1", Instant.fromEpochSeconds(4_000))
|
||||
seedGroupEvent("2", Instant.fromEpochSeconds(6_000))
|
||||
|
||||
val report = reindex()
|
||||
|
||||
assertEquals(2, report.stored)
|
||||
assertEquals(1, report.predatingMembership)
|
||||
assertEquals(1, report.unresolved, "only the event from after the join was replayed")
|
||||
}
|
||||
|
||||
/**
|
||||
* A room joined before `joinedGroupAt` existed. `createdAt` is the same
|
||||
* answer -- a joiner's row is written from the Welcome -- so the fix reaches
|
||||
* rooms that were already on the device rather than only ones joined since.
|
||||
*/
|
||||
@Test
|
||||
fun `a room with no recorded join still holds back its pre-join backlog`() = runBlocking {
|
||||
seedRoom(joinedGroupAt = null)
|
||||
val before = seedGroupEvent("1", Instant.fromEpochSeconds(4_000))
|
||||
|
||||
val report = reindex()
|
||||
|
||||
assertNull(lineFor(before))
|
||||
assertEquals(0, report.unresolved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package press.mantra.compose.database.migrations
|
||||
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.sqlite.execSQL
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The v14 -> v15 migration, against a real database holding the lines it exists
|
||||
* to clear out.
|
||||
*
|
||||
* Fixing the write path only stops the next one. Nothing rewrites a chat line
|
||||
* that is already in the transcript, so a member who joined a busy room a week
|
||||
* ago would go on opening it to the same run of "Undecryptable Message" -- one
|
||||
* per message the group sent before they arrived -- however many versions later.
|
||||
* That is the half of this migration worth asserting.
|
||||
*
|
||||
* What it must not do matters just as much. The delete is aimed at rows that say
|
||||
* nothing by design, and everything else in the transcript is the final word on
|
||||
* its group event: a message that was read, a commit that was applied, a line
|
||||
* this device wrote for something it sent. A rule with a join and a comparison in
|
||||
* it can take too much, and there is no undo.
|
||||
*
|
||||
* Run against the migration's own SQL on a bare connection rather than through
|
||||
* Room, the way `ChronicleRenameMigrationJvmTest` is: Room's version wiring
|
||||
* belongs to `PlatformDatabaseBuilder` and is the same for every migration in
|
||||
* that list.
|
||||
*/
|
||||
class JoinedGroupAtMigrationJvmTest {
|
||||
|
||||
private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:")
|
||||
|
||||
@AfterTest
|
||||
fun close() = connection.close()
|
||||
|
||||
/** v14's three tables, verbatim from `schemas/14.json`. */
|
||||
private fun createV14() {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `ChatRoom` (`id` TEXT NOT NULL, " +
|
||||
"`userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, " +
|
||||
"`mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, " +
|
||||
"`chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, " +
|
||||
"`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, " +
|
||||
"`deletedAt` INTEGER, PRIMARY KEY(`id`), " +
|
||||
"FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
|
||||
"FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `MarmotGroupEvent` (`id` TEXT NOT NULL, " +
|
||||
"`userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, " +
|
||||
"`chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, " +
|
||||
"`encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, " +
|
||||
"`createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, " +
|
||||
"`savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, " +
|
||||
"PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `ChatMessage` (" +
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, " +
|
||||
"`giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, " +
|
||||
"`marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, " +
|
||||
"`replyToMessageId` INTEGER, `quotedMessageId` INTEGER, " +
|
||||
"`content` TEXT NOT NULL, `messageType` TEXT NOT NULL, " +
|
||||
"`directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, " +
|
||||
"`createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, " +
|
||||
"`savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, " +
|
||||
"FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
|
||||
"FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
|
||||
"FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE , " +
|
||||
"FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
}
|
||||
|
||||
/** A room this device wrote down at [createdAt], which is when it joined. */
|
||||
private fun insertRoom(id: String = "room", createdAt: Long) = connection.execSQL(
|
||||
"INSERT INTO `ChatRoom` VALUES ('$id', 'user', NULL, NULL, NULL, NULL, NULL, NULL, " +
|
||||
"$createdAt, $createdAt, $createdAt, NULL, NULL)"
|
||||
)
|
||||
|
||||
private fun insertGroupEvent(id: String, createdAt: Long, room: String = "room") =
|
||||
connection.execSQL(
|
||||
"INSERT INTO `MarmotGroupEvent` (`id`, `userPublicKey`, `publicKey`, `chatRoomId`, " +
|
||||
"`signature`, `encryptedContent`, `createdAt`, `updatedAt`, `savedAt`) " +
|
||||
"VALUES ('$id', 'user', 'sender', '$room', 'sig', 'ciphertext', " +
|
||||
"$createdAt, $createdAt, $createdAt)"
|
||||
)
|
||||
|
||||
private fun insertLine(
|
||||
groupEventId: String?,
|
||||
messageType: String,
|
||||
room: String = "room",
|
||||
content: String = "line",
|
||||
) = connection.execSQL(
|
||||
"INSERT INTO `ChatMessage` (`senderPublicKey`, `isUserMessage`, `marmotGroupEventId`, " +
|
||||
"`chatRoomId`, `content`, `messageType`, `createdAt`, `updatedAt`, `savedAt`) " +
|
||||
"VALUES ('user', 0, ${groupEventId?.let { "'$it'" } ?: "NULL"}, '$room', " +
|
||||
"'$content', '$messageType', 1000, 1000, 1000)"
|
||||
)
|
||||
|
||||
private fun lines(): List<String> =
|
||||
connection.prepare("SELECT `content` FROM `ChatMessage` ORDER BY `id`").use { statement ->
|
||||
buildList { while (statement.step()) add(statement.getText(0)) }
|
||||
}
|
||||
|
||||
private fun columns(table: String): List<String> =
|
||||
connection.prepare("PRAGMA table_info(`$table`)").use { statement ->
|
||||
buildList { while (statement.step()) add(statement.getText(1)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the column arrives, and every room reads as not having recorded a join`() = runBlocking {
|
||||
createV14()
|
||||
insertRoom(createdAt = 5_000)
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertTrue("joinedGroupAt" in columns("ChatRoom"))
|
||||
// Null on purpose. It already means "ask createdAt" -- see
|
||||
// ChatRoom.memberSince -- and backfilling it would turn a fallback into
|
||||
// a claim this migration is in no position to make.
|
||||
connection.prepare("SELECT `joinedGroupAt` FROM `ChatRoom`").use { statement ->
|
||||
assertTrue(statement.step() && statement.isNull(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ALTER TABLE ... ADD COLUMN` appends, so the column lands last rather than
|
||||
* where v15 declares it. Pinned because it looks like a mismatch and is not:
|
||||
* Room compares a table's columns by name, and its own generated migration
|
||||
* for a nullable addition appends in exactly this way.
|
||||
*/
|
||||
@Test
|
||||
fun `the column is appended, which is where Room's own migrations put one`() = runBlocking {
|
||||
createV14()
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertEquals("joinedGroupAt", columns("ChatRoom").last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the placeholder lines for messages sent before this device joined are cleared`() =
|
||||
runBlocking {
|
||||
createV14()
|
||||
insertRoom(createdAt = 5_000)
|
||||
insertGroupEvent("before", createdAt = 4_000)
|
||||
insertLine("before", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "gone")
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertEquals(emptyList(), lines())
|
||||
}
|
||||
|
||||
/**
|
||||
* A commit held back because two competed for the same epoch reads the same
|
||||
* way to the transcript, so it goes by the same rule. Both types are
|
||||
* [ChatMessage.UNRESOLVED_MARMOT_TYPES] -- lines that stand in for an event
|
||||
* that was never read -- and removing one loses nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a pending commit line from before the join goes too`() = runBlocking {
|
||||
createV14()
|
||||
insertRoom(createdAt = 5_000)
|
||||
insertGroupEvent("before", createdAt = 4_000)
|
||||
insertLine("before", ChatMessage.TYPE_PENDING_COMMIT, content = "gone")
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertEquals(emptyList(), lines())
|
||||
}
|
||||
|
||||
/**
|
||||
* The one this rule could get wrong. A placeholder for an event from after
|
||||
* the join is a message still waiting on a commit that has not landed, and a
|
||||
* replay is expected to recover it -- see `NostrDao.reindexMarmotGroupEvents`.
|
||||
*/
|
||||
@Test
|
||||
fun `a placeholder for an event from after the join is left to be recovered`() = runBlocking {
|
||||
createV14()
|
||||
insertRoom(createdAt = 5_000)
|
||||
insertGroupEvent("after", createdAt = 6_000)
|
||||
insertLine("after", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "still waiting")
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertEquals(listOf("still waiting"), lines())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message that was read is left alone however old it is`() = runBlocking {
|
||||
createV14()
|
||||
insertRoom(createdAt = 5_000)
|
||||
insertGroupEvent("read", createdAt = 4_000)
|
||||
insertLine("read", "message", content = "hello")
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertEquals(listOf("hello"), lines())
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines with no group event behind them: NIP-17 messages, and everything a
|
||||
* session writes about itself. The delete reaches them through
|
||||
* `marmotGroupEventId`, and a null one joins to nothing, but SQL nulls are
|
||||
* quiet enough about it to be worth an assertion.
|
||||
*/
|
||||
@Test
|
||||
fun `a line with no group event behind it is out of reach of the rule`() = runBlocking {
|
||||
createV14()
|
||||
insertRoom(createdAt = 5_000)
|
||||
insertLine(null, ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, content = "not marmot")
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertEquals(listOf("not marmot"), lines())
|
||||
}
|
||||
|
||||
/**
|
||||
* The comparison is per room. Two rooms joined at different times share one
|
||||
* transcript table, and a run of placeholders in the older room says nothing
|
||||
* about the newer one's.
|
||||
*/
|
||||
@Test
|
||||
fun `each room is measured against its own join`() = runBlocking {
|
||||
createV14()
|
||||
insertRoom(id = "old", createdAt = 1_000)
|
||||
insertRoom(id = "new", createdAt = 9_000)
|
||||
insertGroupEvent("inOld", createdAt = 4_000, room = "old")
|
||||
insertGroupEvent("inNew", createdAt = 4_000, room = "new")
|
||||
insertLine("inOld", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, room = "old", content = "kept")
|
||||
insertLine("inNew", ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, room = "new", content = "gone")
|
||||
|
||||
MIGRATION_14_15.migrate(connection)
|
||||
|
||||
assertEquals(listOf("kept"), lines())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package press.mantra.compose.database.repository
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.MarmotKeyPackage
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.exceptions.MarmotMissingChatGroupException
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the room is told when an invite does not happen.
|
||||
*
|
||||
* `MarmotOutboundDao.inviteMemberToChatRoom` writes the invite's transcript line inside its
|
||||
* own transaction, which is right: an invite that does not survive `addMember` must not leave
|
||||
* the room claiming one was made. But it means a refusal takes the only account of itself
|
||||
* down with it, and the screen that asked has closed by the time anybody looks -- so the
|
||||
* repository is where a failure has to be written from, outside the transaction that rolled
|
||||
* back.
|
||||
*/
|
||||
class DatabaseChatRepositoryJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val user = KeyPair().pubKey.toHexKey()
|
||||
private val peer = KeyPair().pubKey.toHexKey()
|
||||
private val roomId = "b".repeat(64)
|
||||
|
||||
private val repository = DatabaseChatRepository(
|
||||
database = db,
|
||||
scope = CoroutineScope(Dispatchers.Unconfined),
|
||||
)
|
||||
|
||||
private suspend fun seedProfile(publicKey: String, name: String, nostrEventId: String) {
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
|
||||
/** A room restored from an inbound gift wrap: no MLS state, so nothing to invite into. */
|
||||
private suspend fun seedStatelessRoom(): LocalChatRoom {
|
||||
seedProfile(user, "user", "c".repeat(64))
|
||||
seedProfile(peer, "peer", "e".repeat(64))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = user,
|
||||
subject = "a restored room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
return LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
private fun keyPackage() = MarmotKeyPackage(
|
||||
id = "d".repeat(64),
|
||||
publicKey = peer,
|
||||
tlsEncodedMarmotKeyPackage = ByteArray(0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a refused invite is written into the room it was refused for`() = runBlocking<Unit> {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
assertFailsWith<MarmotMissingChatGroupException> {
|
||||
repository.inviteMember(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
|
||||
val line = db.chatMessageDao().getChatMessagesByChatRoomId(roomId)
|
||||
.map { it.chatMessage }
|
||||
.singleOrNull { it.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED }
|
||||
|
||||
assertNotNull(line, "the refused invite left no line in the room")
|
||||
assertTrue(line.content.contains("peer"), "the line does not name the invitee: ${line.content}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller still has to hear about it. The transcript line is the copy that is there
|
||||
* tomorrow; the throw is what puts a message on the invite screen today, and swallowing it
|
||||
* would pop the user back to the chat as though the invite had gone out.
|
||||
*/
|
||||
@Test
|
||||
fun `a refused invite still reaches the caller`() = runBlocking<Unit> {
|
||||
val localChatRoom = seedStatelessRoom()
|
||||
|
||||
assertFailsWith<MarmotMissingChatGroupException> {
|
||||
repository.inviteMember(
|
||||
localChatRoom = localChatRoom,
|
||||
peerPublicKey = peer,
|
||||
peerKeyPackage = keyPackage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user