From f7580c7f886c6e62c37dd0ac120bb3cf9e7c69c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:21:00 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq --- .../loggedIn/DecryptAndIndexProcessor.kt | 21 +++ .../loggedIn/chats/feed/ChatFeedView.kt | 63 +++++-- .../loggedIn/chats/feed/ChatMessageCompose.kt | 26 ++- ...derConcordEdits.kt => RenderEditedNote.kt} | 13 +- .../chats/marmotGroup/MarmotGroupChatView.kt | 4 + .../marmotGroup/MarmotSystemRowRenderer.kt | 173 ++++++++++++++++++ .../composeResources/values/strings.xml | 18 ++ .../commons/model/NoteEditOverlays.kt | 28 ++- .../model/marmotGroups/MarmotGroupList.kt | 9 +- .../commons/model/MarmotEditOverlayTest.kt | 134 ++++++++++++++ 10 files changed, 454 insertions(+), 35 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/{RenderConcordEdits.kt => RenderEditedNote.kt} (86%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/MarmotEditOverlayTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 6414d19f79..4cdb1c0696 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index d3c56a6717..317608eda0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -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? = 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? = 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? = 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, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 49d7723bca..f3f32961c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -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(initialValue = null, note.idHex) { note.flow().edits.stateFlow.collect { - value = note.latestConcordEdit() ?: note.latestBuzzEdit() + value = note.latestConcordEdit() ?: note.latestBuzzEdit() ?: note.latestMarmotEdit() } } return latest diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt index 0a877d54eb..c2a86864b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEditedNote.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 472b6d8f11..3b7b7b3966 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -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) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt new file mode 100644 index 0000000000..956b2b5256 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotSystemRowRenderer.kt @@ -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) + } +} diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 6ad3c345df..ab2fee6cfd 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -2648,6 +2648,24 @@ Enter group description (optional) Changes will be committed to the group via MLS and propagated to all members. Group icon + + %1$s added %2$s + %1$s joined + %1$s removed %2$s + %1$s was removed + %1$s left + %1$s made %2$s an admin + %1$s is now an admin + %1$s removed %2$s as an admin + %1$s is no longer an admin + %1$s renamed the group to %2$s + The group was renamed to %1$s + %1$s changed the group avatar + The group avatar changed + %1$s disbanded the group + The group was disbanded Remove photo KeyPackage Relays not set 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? diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt index 0e177af03b..0a97168e81 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 2e45d96d63..b0927b5aab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -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, ) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/MarmotEditOverlayTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/MarmotEditOverlayTest.kt new file mode 100644 index 0000000000..a08737f820 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/MarmotEditOverlayTest.kt @@ -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()) + } +}