feat(marmot): show edits and system rows in the Android chat

Two app payload kinds already round-tripped through the protocol layer and
then stopped at the feed: a kind:1009 edit was indexed but nothing drew it,
and a kind:1210 system row was filtered out of the message list entirely.

Edits reuse the overlay rail the other chat protocols already use.
`latestMarmotEdit()` picks the winner off a message's own `edits` children,
and it is deliberately read-side: the transport cannot stop a member from
sending a well-formed 1009 that names someone else's message, so the reader
is the one that has to check the author matches. Ties on the same second
resolve by event id, otherwise two devices of one account could leave two
readers rendering different text for the same message forever, with neither
of them wrong. `RenderConcordEditedNote` was already exactly the renderer
this needs, so it loses the protocol from its name and gains a Marmot caller.

System rows needed somewhere to go. `ChatFeedRowRenderer` is a hook the feed
consults per item: a renderer claims a note, or the ordinary bubble draws it.
That keeps a Marmot-shaped row out of the generic chat feed, which serves
four other protocols. `MarmotSystemRowRenderer` reads the row's structured
fields rather than its `text`, so the caption is localized here instead of
being whatever string the sender happened to compose; `text` stays as the
fallback for a row whose fields we cannot read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-09 19:21:00 +00:00
parent 1794ed5249
commit f7580c7f88
10 changed files with 454 additions and 35 deletions
@@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
import com.vitorpamplona.quartz.marmot.WelcomeResult
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -675,6 +677,25 @@ class GroupEventHandler(
cache.copyRelaysFromTo(outerNote, innerEvent.id)
}
// A kind:1009 edit is anchored to the message it replaces,
// exactly like a Concord edit or a reaction: the bubble reads
// `Note.edits`, and holding the edit as a hard-referenced
// child of its target is what keeps it alive as long as that
// target is. A Marmot inner event is decrypted exactly once —
// the ratchet has moved on by the time anyone could re-fetch
// it — so an edit left orphaned in the soft cache could be
// collected and never come back.
//
// The overlay's own rules (author-only, latest wins) are
// applied at render time by `Note.latestMarmotEdit`, not here:
// the target's author is not necessarily known yet when the
// edit arrives, and a link is not an endorsement.
if (innerEvent.kind == MarmotAppEvent.KIND_EDIT) {
MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit ->
cache.getOrCreateNote(edit.targetId).addEdit(innerNote)
}
}
// Track the message in the Marmot group chatroom
account.marmotGroupList.addMessage(result.groupId, innerNote)
@@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -51,6 +52,23 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
/**
* A caller's own rendering for feed rows that are not chat bubbles.
*
* Marmot's kind:1210 group system rows are the case this exists for: they sit
* in the conversation in chronological order but are captions about group
* state, not messages, and rendering one as a bubble would show a reader raw
* JSON attributed to whoever committed the change.
*/
@Immutable
interface ChatFeedRowRenderer {
/** True when this renderer takes the row instead of the normal bubble. */
fun claims(note: Note): Boolean
@Composable
fun Render(note: Note)
}
@Composable
fun RefreshingChatroomFeedView(
feedContentState: FeedContentState,
@@ -81,6 +99,9 @@ fun RefreshingChatroomFeedView(
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
// Optional per-row override for rows the caller renders itself rather than as
// a chat bubble. Null for every surface whose feed is only messages.
rowRenderer: ChatFeedRowRenderer? = null,
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -99,6 +120,7 @@ fun RefreshingChatroomFeedView(
jumpToNoteId,
onJumpHandled,
onWantsToEditChatMessage,
rowRenderer,
)
}
}
@@ -119,6 +141,7 @@ fun RenderChatFeedView(
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
rowRenderer: ChatFeedRowRenderer? = null,
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -152,6 +175,7 @@ fun RenderChatFeedView(
jumpToNoteId,
onJumpHandled,
onWantsToEditChatMessage,
rowRenderer,
)
}
}
@@ -174,6 +198,7 @@ fun ChatFeedLoaded(
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
rowRenderer: ChatFeedRowRenderer? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -256,20 +281,30 @@ fun ChatFeedLoaded(
older?.event?.createdAt,
)
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
onWantsToEditChatMessage = onWantsToEditChatMessage,
)
// A claimed row is rendered by the caller instead of as a
// bubble. The date divisor above still applies — a system
// row belongs under the day it happened on like anything
// else — which is why the claim is checked here and not
// around the whole item.
val claimed = rowRenderer?.takeIf { it.claims(item) }
if (claimed != null) {
claimed.Render(item)
} else {
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
onWantsToEditChatMessage = onWantsToEditChatMessage,
)
}
}
}
}
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
import com.vitorpamplona.amethyst.commons.model.latestConcordEdit
import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit
import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -67,8 +68,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChan
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderConcordEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
@@ -81,6 +82,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
@@ -621,14 +623,20 @@ fun NoteRow(
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
else -> {
// Concord and Buzz channels overlay edits on their messages (kind-3302 and
// kind-40003): when one exists, render the newest edit's content instead of the
// stale original. One observer for both — a message is only ever one kind, so a
// single edits-flow collector per row covers both (and is null for other surfaces).
// Concord, Buzz and Marmot all overlay edits on their messages (kinds 3302,
// 40003 and 1009): when one exists, render the winning edit's content instead of
// the stale original. One observer for all three — a message is only ever one
// kind, so a single edits-flow collector per row covers them (and is null for
// other surfaces).
val edit = observeChatEdit(note)
when (edit?.event) {
is ConcordChatEditEvent -> RenderConcordEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
is StreamMessageEditEvent -> RenderBuzzEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
val editEvent = edit?.event
when {
editEvent is ConcordChatEditEvent -> RenderEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
editEvent is StreamMessageEditEvent -> RenderBuzzEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
// A Marmot edit is a plain inner app event, not a typed
// class, so it is matched on its kind rather than its type.
editEvent != null && editEvent.kind == MarmotAppEvent.KIND_EDIT ->
RenderEditedNote(note, edit, canPreview, innerQuote, bgColor, accountViewModel, nav)
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
}
}
@@ -646,7 +654,7 @@ fun observeChatEdit(note: Note): Note? {
val latest by
produceState<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value = note.latestConcordEdit() ?: note.latestBuzzEdit()
value = note.latestConcordEdit() ?: note.latestBuzzEdit() ?: note.latestMarmotEdit()
}
}
return latest
@@ -40,12 +40,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* A Concord chat message whose content has been superseded by a kind-3302 edit:
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
* marker, matching the Concord reference client's last-write-wins presentation.
* A chat message whose content has been superseded by an edit: renders the
* WINNING edit's content (never the stale original) plus an "(edited)" marker.
*
* Which edit wins is decided per surface before this is called — Concord by
* CORD-02 send time, Marmot by `created_at` with an event-id tie-break — and is
* always author-only. The rendering itself has nothing surface-specific in it:
* an edit is a body plus its own tags, so the content, the custom emoji and the
* mentions all come off the edit rather than the original.
*/
@Composable
fun RenderConcordEditedNote(
fun RenderEditedNote(
note: Note,
editNote: Note,
canPreview: Boolean,
@@ -148,6 +148,10 @@ fun MarmotGroupChatView(
routeForLastRead = marmotGroupLastReadRoute(nostrGroupId),
onWantsToReply = { note -> newMessageModel.reply(note) },
onWantsToEditDraft = { },
// kind:1210 rows sit in the conversation in order but are
// group-state captions rather than messages, so they get their
// own centered style instead of a bubble.
rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) },
)
}
@@ -0,0 +1,173 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_added
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_added_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_removed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_admin_removed_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_avatar_changed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_avatar_changed_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_disbanded
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_disbanded_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_renamed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_group_renamed_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_added
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_added_passive
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_left
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_removed
import com.vitorpamplona.amethyst.commons.resources.marmot_system_member_removed_passive
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatFeedRowRenderer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemType
import org.jetbrains.compose.resources.StringResource
/**
* Renders a kind:1210 group system row as a centered caption.
*
* These are not chat and must not read like it. A row is derived locally from
* canonical group state rather than received as a message, so it has no sender
* to attribute a bubble to — and its `content` is JSON, which a chat bubble
* would render verbatim.
*
* The caption is built from the row's STRUCTURED fields, with its `text` member
* used only as a fallback. That ordering is the spec's ("Clients SHOULD render
* from the structured fields instead") and it is what lets the same row read in
* the viewer's own terms rather than the writer's.
*/
class MarmotSystemRowRenderer(
private val accountViewModel: AccountViewModel,
) : ChatFeedRowRenderer {
override fun claims(note: Note): Boolean = note.event?.kind == MarmotAppEvent.KIND_SYSTEM
@Composable
override fun Render(note: Note) {
val event = note.event ?: return
val row = remember(event.id) { MarmotSystemEvent.fromAppEvent(MarmotAppEvent.fromEvent(event)) }
// An unknown `system_type` decodes to null rather than throwing, because
// the registry grows and an unfamiliar row must not break the feed. There
// is nothing honest to draw for one, so it is simply not drawn.
if (row == null) return
val caption = caption(row)
// A two-party row with no subject has nothing true to say; the spec's
// fallback text would be a generic label, not information.
if (caption.isEmpty()) return
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = caption,
textAlign = TextAlign.Center,
fontSize = 12.sp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* The row in words, naming people rather than pubkeys where we know them.
*
* Each type has an active and a passive phrasing: the actor is optional —
* a row derived from a commit whose committer we cannot attribute is still
* a true row — so "who did it" is never assumed. The row's own `text`
* member is the last fallback, which is what it exists for.
*/
@Composable
private fun caption(row: MarmotSystemEvent): String {
val actor = row.actor?.let { displayName(it) }
val subject = row.subject?.let { displayName(it) }
return when (row.systemType) {
MarmotSystemType.MEMBER_ADDED ->
twoParty(subject, actor, Res.string.marmot_system_member_added, Res.string.marmot_system_member_added_passive)
MarmotSystemType.MEMBER_REMOVED ->
twoParty(subject, actor, Res.string.marmot_system_member_removed, Res.string.marmot_system_member_removed_passive)
MarmotSystemType.MEMBER_LEFT ->
subject?.let { stringRes(Res.string.marmot_system_member_left, it) } ?: row.text
MarmotSystemType.ADMIN_ADDED ->
twoParty(subject, actor, Res.string.marmot_system_admin_added, Res.string.marmot_system_admin_added_passive)
MarmotSystemType.ADMIN_REMOVED ->
twoParty(subject, actor, Res.string.marmot_system_admin_removed, Res.string.marmot_system_admin_removed_passive)
MarmotSystemType.GROUP_RENAMED ->
row.name?.let { name ->
if (actor != null) {
stringRes(Res.string.marmot_system_group_renamed, actor, name)
} else {
stringRes(Res.string.marmot_system_group_renamed_passive, name)
}
} ?: row.text
MarmotSystemType.GROUP_AVATAR_CHANGED ->
actor?.let { stringRes(Res.string.marmot_system_avatar_changed, it) }
?: stringRes(Res.string.marmot_system_avatar_changed_passive)
MarmotSystemType.GROUP_DISBANDED ->
actor?.let { stringRes(Res.string.marmot_system_group_disbanded, it) }
?: stringRes(Res.string.marmot_system_group_disbanded_passive)
}
}
/**
* A row about one member, phrased actively when the committer is known and
* passively when it is not.
*/
@Composable
private fun twoParty(
subject: String?,
actor: String?,
active: StringResource,
passive: StringResource,
): String {
if (subject == null) return ""
return if (actor != null) stringRes(active, actor, subject) else stringRes(passive, subject)
}
/** A known display name, or a short key when the account is a stranger. */
@Composable
private fun displayName(pubkeyHex: String): String {
val user = accountViewModel.getUserIfExists(pubkeyHex)
return user?.toBestDisplayName() ?: pubkeyHex.take(8)
}
}
@@ -2648,6 +2648,24 @@
<string name="marmot_group_description_placeholder">Enter group description (optional)</string>
<string name="marmot_edit_info_footer">Changes will be committed to the group via MLS and propagated to all members.</string>
<string name="marmot_group_icon">Group icon</string>
<!-- Marmot kind:1210 group system rows. Rendered as centered captions in the
conversation, from the row's structured fields. %1$s is the member who
committed the change, %2$s the member it concerns, %3$s the new name. -->
<string name="marmot_system_member_added">%1$s added %2$s</string>
<string name="marmot_system_member_added_passive">%1$s joined</string>
<string name="marmot_system_member_removed">%1$s removed %2$s</string>
<string name="marmot_system_member_removed_passive">%1$s was removed</string>
<string name="marmot_system_member_left">%1$s left</string>
<string name="marmot_system_admin_added">%1$s made %2$s an admin</string>
<string name="marmot_system_admin_added_passive">%1$s is now an admin</string>
<string name="marmot_system_admin_removed">%1$s removed %2$s as an admin</string>
<string name="marmot_system_admin_removed_passive">%1$s is no longer an admin</string>
<string name="marmot_system_group_renamed">%1$s renamed the group to %2$s</string>
<string name="marmot_system_group_renamed_passive">The group was renamed to %1$s</string>
<string name="marmot_system_avatar_changed">%1$s changed the group avatar</string>
<string name="marmot_system_avatar_changed_passive">The group avatar changed</string>
<string name="marmot_system_group_disbanded">%1$s disbanded the group</string>
<string name="marmot_system_group_disbanded_passive">The group was disbanded</string>
<string name="marmot_remove_photo">Remove photo</string>
<string name="marmot_keypackage_relays_not_set_title">KeyPackage Relays not set</string>
<string name="marmot_keypackage_relays_not_set_message">You don't have a KeyPackage Relay List yet (MIP-00). This list tells other people where your KeyPackage is published so they can invite you to group chats.\n\nUse your current outbox relays for this?</string>
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -33,9 +34,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
* note rather than the cache.
*
* All three kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord)
* is trusted to enforce that, so a foreign-authored edit never rewrites your message.
* All four kinds apply ONLY edits authored by the edited note's own author: the send side gates
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord,
* Marmot) is trusted to enforce that, so a foreign-authored edit never rewrites your message.
*/
/**
@@ -61,6 +62,27 @@ fun Note.latestBuzzEdit(): Note? {
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
}
/**
* The kind-1009 Marmot edit overlaying this message, or null.
*
* Marmot fixes both halves of this rule in `foundation/application-messages.md`
* ("Message edits"): only the original author's account may replace a message,
* and the latest `created_at` wins with the event id breaking a tie. The
* tie-break is not decoration — two devices of one account can stamp the same
* second, and without it two readers would render different text for the same
* message forever.
*
* Authorship is by ACCOUNT, which is what `author?.pubkeyHex` already is for a
* Marmot inner event: a second device of the same account holds a different MLS
* leaf but the same account key, and may edit its own account's message.
*/
fun Note.latestMarmotEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
return edits
.filter { it.author?.pubkeyHex == authorHex && it.event?.kind == MarmotAppEvent.KIND_EDIT }
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
}
/** The kind-3302 Concord edit overlaying this message, or null — author-only, newest by CORD-02 §4 send time. */
fun Note.latestConcordEdit(): Note? {
val authorHex = author?.pubkeyHex ?: return null
@@ -139,9 +139,10 @@ class MarmotGroupList(
* payload is routing metadata with an empty body, so it would render as
* a blank bubble. What a reader sees is the live preview and then the
* authoritative kind:9.
* - **1210 system rows** are group-state captions, not messages. They are
* held back here rather than shown as a bubble of JSON; a renderer with
* a system-row style can surface them from the same log.
* 1210 system rows are NOT in this list. They are group-state captions
* rather than messages, but they belong in the conversation in
* chronological order, so the feed carries them and the renderer gives
* them their own style instead of a chat bubble.
*/
private fun isDisplayableFeedMessage(msg: Note): Boolean {
val kind = msg.event?.kind ?: return true
@@ -153,7 +154,6 @@ class MarmotGroupList(
private const val MARMOT_INNER_KIND_REACTION = 7
private const val MARMOT_INNER_KIND_EDIT = 1009
private const val MARMOT_INNER_KIND_STREAM_START = 1200
private const val MARMOT_INNER_KIND_SYSTEM = 1210
private val NON_CHAT_INNER_KINDS =
setOf(
@@ -161,7 +161,6 @@ class MarmotGroupList(
MARMOT_INNER_KIND_REACTION,
MARMOT_INNER_KIND_EDIT,
MARMOT_INNER_KIND_STREAM_START,
MARMOT_INNER_KIND_SYSTEM,
)
}
}
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The kind-1009 overlay rule, resolved off a message's own [Note.edits].
*
* Both halves are read-side on purpose: a sender cannot be trusted to have
* applied them, and this is the only place the app decides which text a reader
* actually sees.
*/
class MarmotEditOverlayTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val context = UserContext { addr -> AddressableNote(addr) }
private fun user(pubkey: String) = User(pubkey, context)
private fun event(
id: String,
pubkey: String,
kind: Int,
content: String,
createdAt: Long,
targetId: String? = null,
) = Event(
id = id,
pubKey = pubkey,
createdAt = createdAt,
kind = kind,
tags = targetId?.let { arrayOf(arrayOf("e", it)) } ?: emptyArray(),
content = content,
sig = "",
)
private fun note(event: Event): Note = Note(event.id).also { it.loadEvent(event, user(event.pubKey), emptyList()) }
private fun target(): Note = note(event("0".repeat(64), alice, MarmotAppEvent.KIND_CHAT, "frist post", 1_800_000_000L))
private fun edit(
id: String,
author: String,
content: String,
createdAt: Long,
targetId: String = "0".repeat(64),
) = note(event(id, author, MarmotAppEvent.KIND_EDIT, content, createdAt, targetId))
@Test
fun `an unedited message has no overlay`() {
assertNull(target().latestMarmotEdit())
}
@Test
fun `the author's own edit overlays their message`() {
val message = target()
message.addEdit(edit("1".repeat(64), alice, "first post", 1_800_000_100L))
assertEquals("first post", message.latestMarmotEdit()?.event?.content)
}
@Test
fun `an edit by another account is ignored`() {
// Only the original author may replace their words. The transport
// cannot enforce this — any member can send a well-formed 1009 naming
// someone else's message — so the reader has to.
val message = target()
message.addEdit(edit("2".repeat(64), bob, "not mine", 1_800_000_100L))
assertNull(message.latestMarmotEdit())
}
@Test
fun `the latest edit wins`() {
val message = target()
message.addEdit(edit("1".repeat(64), alice, "v2", 1_800_000_100L))
message.addEdit(edit("2".repeat(64), alice, "v3", 1_800_000_300L))
message.addEdit(edit("3".repeat(64), alice, "v2b", 1_800_000_200L))
assertEquals("v3", message.latestMarmotEdit()?.event?.content)
}
@Test
fun `a same-second pair resolves by event id, identically for every reader`() {
// Two devices of one account can stamp the same second. Without a
// deterministic tie-break two readers would render different text for
// the same message forever, and neither would be wrong.
val stamp = 1_800_000_100L
val ascending = target()
ascending.addEdit(edit("1".repeat(64), alice, "from device A", stamp))
ascending.addEdit(edit("f".repeat(64), alice, "from device B", stamp))
val descending = target()
descending.addEdit(edit("f".repeat(64), alice, "from device B", stamp))
descending.addEdit(edit("1".repeat(64), alice, "from device A", stamp))
assertEquals("from device B", ascending.latestMarmotEdit()?.event?.content)
assertEquals(
ascending.latestMarmotEdit()?.event?.content,
descending.latestMarmotEdit()?.event?.content,
"insertion order must not decide the winner",
)
}
@Test
fun `a non-edit child is not an overlay`() {
// `edits` is a general child list; a reaction or any other kind
// anchored to the message must not be read as replacement text.
val message = target()
message.addEdit(note(event("4".repeat(64), alice, 7, "🍕", 1_800_000_400L)))
assertNull(message.latestMarmotEdit())
}
}