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

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

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

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

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

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

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

550 jvm tests and 319 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-06 20:12:49 +02:00
commit 30c732af67
24 files changed
+7054 -109

No files matched your search

@@ -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)))
}
}
@@ -153,6 +153,25 @@ class ChatRoomLastMessagePreviewTest {
)
}
/**
* 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