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()) + } +}