Merge branch 'mantra' into claude/proposals-view-navigation-9f3a58
Brings in ten commits: `ChatRoom.joinedGroupAt` and the pre-join indexing gate
with schema v15, the home list's last-message preview and its `chatRoomId`
index at v16, the membership transcript lines, and the widening of the notary's
third queue.
No conflict. mantra touches two of this branch's four files and meets neither
change. In `ChatMessageListViewModel` its edits are a `MEMBERSHIP_TYPES` branch
in the items loop and three icons and a tint in `RitualNotice`; this branch's
are the constructor, `hidesOtherDecisions` and `observeProposalsAwaitingYou`.
In `ChatRoomDetailScreen` its edit is the reindex report's wording at the foot
of the screen, a couple of hundred lines below the button that moved.
**The membership branch sits in front of the signing one**, which is worth
checking rather than assuming: both are early returns in the same loop, and
order decides which of them claims a row. Neither can claim the other's.
`MEMBERSHIP_TYPES` is the three invite types and `FROST_TYPES` the eight signing
ones, disjoint sets, and a membership line's `onClick` is `{}` -- it leads
nowhere at all, so it never reaches the routing this branch changed.
**Nothing mantra deletes can take a signing line.** `MIGRATION_14_15` deletes
transcript rows, which is exactly the sort of thing that could quietly empty
the transcript this branch routes from. It cannot: the delete is scoped to
`ChatMessage.UNRESOLVED_MARMOT_TYPES`, which is `TYPE_UNDECRYPTABLE_OUTER_LAYER`
and `TYPE_PENDING_COMMIT` -- placeholders standing in for events that were never
read -- and no FROST type is in that set. Every other line, the signing ones
included, is the final word on its group event and is left alone.
**The pre-join gate and the proposal count agree by construction.** A signing
message is a kind:445 like every other event in a Marmot group, so
`NostrDao.indexMarmotGroupEvent` holds back the ones a group published before
this device joined, and a session proposed before then leaves no rows here at
all. `observeProposalsAwaitingYou` counts sessions rather than lines, so there
is nothing for it to over-count: a member added mid-signing is not told they owe
a decision on something they cannot see, and the transcript is not asked for a
line about a session that was withheld from it. The two changes needed no
reconciling because they are answering about the same withheld events from
opposite ends.
Verified after the merge rather than assumed from before it:
:composeApp:compileDebugKotlinAndroid succeeds; 884 tests pass -- 565 jvm and
319 android, up from 808 because 76 of them are mantra's. This branch adds
none, for the reasons its own commit gives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 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,
|
||||
|
||||
@@ -34,6 +34,35 @@ interface UnsignedNostrEventDao {
|
||||
@Query("SELECT * FROM UnsignedNostrEvent WHERE kind = 0")
|
||||
suspend fun getLocalAccounts(): List<LocalAccount>
|
||||
|
||||
@Query("SELECT * FROM UnsignedNostrEvent WHERE pubKey = :publicKey AND signedAt IS NULL ORDER BY kind ASC")
|
||||
fun observeUnsignedNostrEvents(publicKey: String): Flow<UnsignedNostrEvent?>
|
||||
/**
|
||||
* Everything this key has queued and not yet signed, lowest kind first.
|
||||
*
|
||||
* A backlog, not a head, for the reason spelled out on
|
||||
* `MarmotInnerEventDao.observeUnprocessedMarmotInnerEvents`: the only exit from this
|
||||
* queue is a successful publish stamping `signedAt`, in the transaction
|
||||
* `NostrDao.commitPublishedNostrEvent` wraps. Nothing else clears it, so a row that
|
||||
* cannot be published stays, and while the notary was handed one row at a time it was
|
||||
* the whole queue. The comment on `commitPublishedNostrEvent` is the account of that
|
||||
* happening: an indexing throw rolled `signedAt` back and nothing queued afterwards --
|
||||
* the MLS key package included -- was ever signed.
|
||||
*
|
||||
* `kind ASC` is kept because it is load-bearing: kind 0 sorts first, and
|
||||
* NavigationViewModel holds the user on a screen until their profile is announced. It
|
||||
* also puts the key package (30443) last, which is what the `commitPublishedNostrEvent`
|
||||
* comment means by "enqueued last". Between them sits the relay feeds list (10012) --
|
||||
* the one row of the account burst carrying `privateTags`, and so the only one whose
|
||||
* publish runs a NIP-44 encryption first. A throw there takes both relay lists (10050,
|
||||
* 10051) and the key package with it, which is to say everything a peer needs to reach
|
||||
* this user. That is the shape of the next stall, not a hypothetical one.
|
||||
*
|
||||
* `id ASC` breaks the tie. It is the autogenerated row id, so two events of one kind
|
||||
* are published in the order they were queued -- for a replaceable kind that is the
|
||||
* difference between the newest one standing and an older one overwriting it.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM UnsignedNostrEvent " +
|
||||
"WHERE pubKey = :publicKey AND signedAt IS NULL " +
|
||||
"ORDER BY kind ASC, id ASC"
|
||||
)
|
||||
fun observeUnsignedNostrEvents(publicKey: String): Flow<List<UnsignedNostrEvent>>
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -100,7 +100,7 @@ class DatabaseNostrRepository(
|
||||
database.connectionDao().delete(connection)
|
||||
}
|
||||
|
||||
override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<UnsignedNostrEvent?> {
|
||||
override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<List<UnsignedNostrEvent>> {
|
||||
return database.unsignedNostrEventDao().observeUnsignedNostrEvents(publicKey)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,7 +34,14 @@ interface NostrRepository {
|
||||
|
||||
suspend fun deleteConnection(connection: Connection)
|
||||
|
||||
suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<UnsignedNostrEvent?>
|
||||
/**
|
||||
* The whole unsigned backlog for [publicKey], lowest kind first.
|
||||
*
|
||||
* See `UnsignedNostrEventDao.observeUnsignedNostrEvents`: a row that cannot be
|
||||
* published used to be the entire queue. Callers work through it in order, and a row
|
||||
* that fails costs only itself.
|
||||
*/
|
||||
suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<List<UnsignedNostrEvent>>
|
||||
|
||||
suspend fun observePendingBroadcastNostrEventRequests(): Flow<LocalBroadcastNostrEventRequest?>
|
||||
|
||||
@@ -195,7 +202,7 @@ interface NostrRepository {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<UnsignedNostrEvent?> {
|
||||
override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow<List<UnsignedNostrEvent>> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -637,13 +637,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
|
||||
|
||||
@@ -476,6 +476,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
|
||||
@@ -799,6 +817,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
|
||||
}
|
||||
|
||||
@@ -817,7 +842,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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,24 @@ class NotaryViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and publish everything queued for this key, not just the lowest kind.
|
||||
*
|
||||
* The last of the notary's three queues to be a single row, and the one already
|
||||
* observed to stall: `NostrDao.commitPublishedNostrEvent` carries the account of an
|
||||
* indexing throw rolling `signedAt` back, after which nothing queued behind that row
|
||||
* was ever signed. That was fixed by moving indexing out of the transaction, which
|
||||
* closed the one known way in and left the queue as narrow as it was.
|
||||
*
|
||||
* `UnsignedNostrEvent.equals` is a plain value comparison, so this was the frozen
|
||||
* variant rather than the retrying one: the failed row's re-emission compared equal to
|
||||
* the last, `distinctUntilChanged` dropped it, and the collector saw nothing again for
|
||||
* the life of the session. Both go, for the reason given on
|
||||
* [observeUnprocessedMarmotInnerEvents].
|
||||
*
|
||||
* Serially and in the query's order: kind 0 is what NavigationViewModel is waiting on,
|
||||
* and a replaceable kind queued twice must go out oldest first or the older one wins.
|
||||
*/
|
||||
private suspend fun observeUnsignedNostrEvents(
|
||||
keyPair: KeyPair
|
||||
) {
|
||||
@@ -118,8 +136,11 @@ class NotaryViewModel(
|
||||
logger.i { "observeUnsignedNostrEvents: ${keyPair.pubKey.toHexKey()}" }
|
||||
nostrRepository.observeUnsignedNostrEvents(
|
||||
publicKey = keyPair.pubKey.toHexKey()
|
||||
).distinctUntilChanged().collect { unsignedNostrEventOrNull ->
|
||||
unsignedNostrEventOrNull?.let { unsignedNostrEvent ->
|
||||
).collect { unsignedNostrEvents ->
|
||||
unsignedNostrEvents.forEach { unsignedNostrEvent ->
|
||||
// Per row: a publish that throws rolls back its own transaction and
|
||||
// nothing else, so the row stays queued for the next pass and the rest of
|
||||
// the account's events still go out.
|
||||
guardNotarization("unsigned event ${unsignedNostrEvent.id} (kind ${unsignedNostrEvent.kind})") {
|
||||
logger.d("Unsigned: ${unsignedNostrEvent.kind}")
|
||||
val event = tempSigner.signNormal<Event>(
|
||||
|
||||
@@ -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,243 @@
|
||||
package press.mantra.compose.database.dao
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.UnsignedNostrEvent
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The notary's unsigned queue, and what an event it cannot publish does to the rest.
|
||||
*
|
||||
* The last of the three queues to hand back one row at a time, and the only one already
|
||||
* observed to stall: the comment on `NostrDao.commitPublishedNostrEvent` records an indexing
|
||||
* throw rolling `signedAt` back, after which nothing queued behind that row was ever signed --
|
||||
* "including the MLS key package, which is enqueued last". That was closed by moving indexing
|
||||
* into its own transaction (covered in [NostrDaoJvmTest]), which fixed the one known way in
|
||||
* and left the queue as narrow as it was.
|
||||
*
|
||||
* These cover the width instead. `signedAt` is the only exit, so a row that cannot be
|
||||
* published stays; what matters is that it is no longer the only row the notary is shown. The
|
||||
* kinds used are the ones a real account queues -- 0 metadata, 3 contacts, 10012 relay feeds
|
||||
* (the one carrying `privateTags`, and so the only one whose publish encrypts first), 10050
|
||||
* and 10051 relay lists, 30443 key package -- because their sort order is the whole reason a
|
||||
* stall in the middle of that burst leaves a user nobody can reach.
|
||||
*
|
||||
* What is asserted here is the DAO's half: that the backlog arrives whole and in a stable
|
||||
* order, and that publishing some rows neither disturbs nor depends on the others. The loop
|
||||
* that walks it lives in NotaryViewModel, which wants an ActiveWallet flow to stand up; the
|
||||
* [drain] below is shaped like it but is this test's own, so it pins the queue rather than the
|
||||
* collector.
|
||||
*/
|
||||
class UnsignedNostrEventQueueJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val keyPair = KeyPair()
|
||||
private val author = keyPair.pubKey.toHexKey()
|
||||
private val signer = NostrSignerSync(keyPair)
|
||||
private val relays = listOf("wss://one.example", "wss://two.example")
|
||||
|
||||
private suspend fun queue(
|
||||
kind: Int,
|
||||
content: String,
|
||||
pubKey: String = author,
|
||||
privateTags: Array<Array<String>>? = null,
|
||||
): Long = db.unsignedNostrEventDao().upsert(
|
||||
UnsignedNostrEvent(
|
||||
pubKey = pubKey,
|
||||
kind = kind,
|
||||
tags = emptyArray(),
|
||||
privateTags = privateTags,
|
||||
content = content,
|
||||
createdAt = Instant.fromEpochSeconds(1_000),
|
||||
)
|
||||
)
|
||||
|
||||
private suspend fun backlog(publicKey: String = author) = db.unsignedNostrEventDao()
|
||||
.observeUnsignedNostrEvents(publicKey)
|
||||
.first()
|
||||
|
||||
/** Sign and publish one row exactly as the notary does, private tags and all. */
|
||||
private suspend fun publish(unsigned: UnsignedNostrEvent) {
|
||||
val event = signer.signNormal<Event>(
|
||||
createdAt = unsigned.createdAt.epochSeconds,
|
||||
kind = unsigned.kind,
|
||||
tags = unsigned.tags,
|
||||
content = unsigned.privateTags?.let { PrivateTagsInContent.encryptNip44(it, signer) }
|
||||
?: unsigned.content,
|
||||
)
|
||||
|
||||
db.nostrDao().publishNostrEvent(
|
||||
unsignedNostrEvent = unsigned,
|
||||
nostrEvent = NostrEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
kind = event.kind,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
sig = event.sig,
|
||||
unsignedNostrEventId = unsigned.id,
|
||||
),
|
||||
relayURLs = relays,
|
||||
activeKeyPair = keyPair,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A pass over the backlog with [failKinds] standing in for rows the notary cannot publish.
|
||||
* A real failure writes nothing -- `commitPublishedNostrEvent` is one transaction -- so
|
||||
* skipping the row leaves the database in the state a throw would have.
|
||||
*/
|
||||
private suspend fun drain(failKinds: Set<Int> = emptySet()): List<Int> {
|
||||
val failed = mutableListOf<Int>()
|
||||
backlog().forEach { unsigned ->
|
||||
if (unsigned.kind in failKinds) {
|
||||
failed += unsigned.kind
|
||||
} else {
|
||||
publish(unsigned)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the queue hands back the whole backlog, lowest kind first`() = runBlocking {
|
||||
queue(kind = 30_443, content = "key package")
|
||||
queue(kind = 0, content = "metadata")
|
||||
queue(kind = 10_050, content = "dm relays")
|
||||
queue(kind = 3, content = "contacts")
|
||||
|
||||
assertEquals(
|
||||
listOf(0, 3, 10_050, 30_443),
|
||||
backlog().map { it.kind },
|
||||
"the notary is handed the backlog to work through, not just its lowest kind",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `id` is the autogenerated row id, so this is the order they were queued in. For a
|
||||
* replaceable kind it is the difference between the newest version standing and an older
|
||||
* one being published last and winning.
|
||||
*/
|
||||
@Test
|
||||
fun `events of one kind come back in the order they were queued`() = runBlocking {
|
||||
queue(kind = 1, content = "first")
|
||||
queue(kind = 1, content = "second")
|
||||
queue(kind = 1, content = "third")
|
||||
|
||||
assertEquals(listOf("first", "second", "third"), backlog().map { it.content })
|
||||
assertEquals(
|
||||
listOf("first", "second", "third"),
|
||||
backlog().map { it.content },
|
||||
"the order changed between passes",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only this key's events are in its backlog`() = runBlocking {
|
||||
val stranger = KeyPair().pubKey.toHexKey()
|
||||
queue(kind = 0, content = "ours")
|
||||
queue(kind = 0, content = "theirs", pubKey = stranger)
|
||||
|
||||
assertEquals(listOf("ours"), backlog().map { it.content })
|
||||
assertEquals(listOf("theirs"), backlog(stranger).map { it.content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a published event leaves the queue`() = runBlocking {
|
||||
queue(kind = 0, content = "metadata")
|
||||
|
||||
drain()
|
||||
|
||||
assertEquals(emptyList(), backlog(), "a signed row is still being offered")
|
||||
}
|
||||
|
||||
/**
|
||||
* The one that matters, and the shape of the stall this queue is most exposed to: 10012 is
|
||||
* the only row of the account burst carrying private tags, and it sorts ahead of both relay
|
||||
* lists a peer needs to find this user and ahead of the key package they need to invite
|
||||
* them. A queue that yields only its head would offer 10012 forever and publish none of the
|
||||
* three.
|
||||
*/
|
||||
@Test
|
||||
fun `an event that cannot be published no longer hides the ones behind it`() = runBlocking {
|
||||
queue(kind = 0, content = "metadata")
|
||||
queue(kind = 10_012, content = "relay feeds", privateTags = arrayOf(arrayOf("relay", "wss://private.example")))
|
||||
queue(kind = 10_050, content = "dm relays")
|
||||
queue(kind = 10_051, content = "key package relays")
|
||||
queue(kind = 30_443, content = "key package")
|
||||
|
||||
assertEquals(listOf(10_012), drain(failKinds = setOf(10_012)))
|
||||
|
||||
assertEquals(
|
||||
listOf(10_012),
|
||||
backlog().map { it.kind },
|
||||
"the stopper stays queued and nothing else does",
|
||||
)
|
||||
assertEquals(
|
||||
listOf(0, 10_050, 10_051, 30_443),
|
||||
db.unsignedNostrEventDao().getAUnsignedNostrEvents()
|
||||
.filter { it.pubKey == author && it.signedAt != null }
|
||||
.map { it.kind }
|
||||
.sorted(),
|
||||
"the events either side of the stopper were not published",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Published means queued for the relays. A row that got `signedAt` but no
|
||||
* BroadcastNostrEventRequest is still an event no relay ever sees, which is the same
|
||||
* silence in a different place -- see 0211764.
|
||||
*/
|
||||
@Test
|
||||
fun `an event past the stopper is queued for every relay`() = runBlocking {
|
||||
queue(kind = 10_012, content = "relay feeds", privateTags = arrayOf(arrayOf("relay", "wss://private.example")))
|
||||
queue(kind = 30_443, content = "key package")
|
||||
|
||||
drain(failKinds = setOf(10_012))
|
||||
|
||||
val keyPackage = assertNotNull(
|
||||
db.unsignedNostrEventDao().getAUnsignedNostrEvents().singleOrNull { it.kind == 30_443 }
|
||||
)
|
||||
assertNotNull(keyPackage.signedAt)
|
||||
val queued = db.broadcastNostrEventRequestDao().getAllBroadcastNostrEventRequests()
|
||||
.filter { it.unsignedNostrEventId == keyPackage.id }
|
||||
assertEquals(relays.toSet(), queued.map { it.relayURL }.toSet())
|
||||
assertTrue(queued.all { it.status == "pending" }, "pending is the only status the broadcaster looks at")
|
||||
}
|
||||
|
||||
/** A skipped row must be left exactly as it was, or the next pass has nothing to retry. */
|
||||
@Test
|
||||
fun `the stopper is not marked signed`() = runBlocking {
|
||||
queue(kind = 10_012, content = "relay feeds")
|
||||
queue(kind = 30_443, content = "key package")
|
||||
|
||||
drain(failKinds = setOf(10_012))
|
||||
|
||||
assertNull(
|
||||
backlog().single().signedAt,
|
||||
"a row still on the queue cannot also be signed",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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