From a5925c16c20e3f2a0d5a218ae9030caa7bf21831 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 22:15:43 +0000 Subject: [PATCH 01/11] feat(concord): let authors edit their own channel messages Brings feed-post-style edits to Concord chat. A Concord message rumor is a standard kind-9 event, so an edit reuses Amethyst's native kind-1010 TextNoteModificationEvent: a channel/epoch-bound rumor that e-tags the target and carries the replacement text, wrapped and published on the same channel plane as any other Chat Plane rumor. Receivers overlay the newest edit through the existing shared machinery (LocalCache.findLatestModificationForNote), which only applies edits authored by the original message's author, so a member can't rewrite someone else's message. Clients that don't understand kind-1010 keep showing the original text, so it degrades gracefully. - ChannelChat.edit + ConcordActions.buildChannelEdit build/wrap the edit rumor. - Account.editConcordChannelMessage gates to my own kind-9 messages and publishes the wrap (local echo + relays), mirroring reactToConcordMessage so the edit never leaks the private rumor id onto public relays. - The chat bubble overlays the newest edit (RenderConcordEditedNote) with an "(edited)" marker, matching the Buzz kind-40003 edit presentation. - The long-press action sheet offers Edit on my own Concord messages; the composer enters edit mode with an editing banner and publishes the edit on send. The former onWantsToEditBuzz callback is generalized to onWantsToEditChatMessage, shared by the Buzz and Concord surfaces. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../vitorpamplona/amethyst/model/Account.kt | 32 +++++ .../loggedIn/chats/feed/ChatFeedView.kt | 12 +- .../chats/feed/ChatMessageActionSheet.kt | 31 ++-- .../loggedIn/chats/feed/ChatMessageCompose.kt | 30 ++-- .../chats/feed/types/RenderConcordEdits.kt | 133 ++++++++++++++++++ .../concord/ConcordChannelScreen.kt | 31 ++++ .../send/ConcordNewMessageViewModel.kt | 28 +++- .../relayGroup/RelayGroupChannelView.kt | 2 +- amethyst/src/main/res/values/strings.xml | 3 + .../commons/actions/ConcordActions.kt | 19 +++ .../concord/cord03Channels/ChannelChat.kt | 37 +++++ .../cord03Channels/ChannelChatEndToEndTest.kt | 25 ++++ 12 files changed, 350 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a5102f3d39..77ec0586e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -361,6 +361,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance @@ -2351,6 +2352,37 @@ class Account( return true } + /** + * Edit my own Concord channel message [note] to [newText]. Mirrors + * [reactToConcordMessage]: builds a kind-1010 [ChannelChat.edit] rumor bound to the + * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays + * inside the encrypted channel (a public kind-1010 would e-tag the private rumor id onto + * public relays). The receiving side overlays the newest edit onto the target message; + * only the *original author's* edits are applied, so we gate to my own kind-9 messages. + * Returns false if [note] isn't an editable Concord message I authored. + */ + suspend fun editConcordChannelMessage( + note: Note, + newText: String, + ): Boolean { + if (!isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + // Edits only apply to plain kind-9 messages, and only the author may edit their own. + if (target !is ChatEvent || target.pubKey != signer.pubKey) return false + + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. + val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray() + val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + /** * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at * most every few seconds while composing. Not folded locally (we never show our own typing); 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 f69acdaa79..4db0191b85 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 @@ -80,7 +80,7 @@ fun RefreshingChatroomFeedView( // callers with no external jump affordance. jumpToNoteId: State? = null, onJumpHandled: () -> Unit = {}, - onWantsToEditBuzz: ((Note) -> Unit)? = null, + onWantsToEditChatMessage: ((Note) -> Unit)? = null, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> listStateObserver(listState) @@ -98,7 +98,7 @@ fun RefreshingChatroomFeedView( sentinels, jumpToNoteId, onJumpHandled, - onWantsToEditBuzz, + onWantsToEditChatMessage, ) } } @@ -118,7 +118,7 @@ fun RenderChatFeedView( sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, jumpToNoteId: State? = null, onJumpHandled: () -> Unit = {}, - onWantsToEditBuzz: ((Note) -> Unit)? = null, + onWantsToEditChatMessage: ((Note) -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -151,7 +151,7 @@ fun RenderChatFeedView( sentinels, jumpToNoteId, onJumpHandled, - onWantsToEditBuzz, + onWantsToEditChatMessage, ) } } @@ -173,7 +173,7 @@ fun ChatFeedLoaded( sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, jumpToNoteId: State? = null, onJumpHandled: () -> Unit = {}, - onWantsToEditBuzz: ((Note) -> Unit)? = null, + onWantsToEditChatMessage: ((Note) -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -268,7 +268,7 @@ fun ChatFeedLoaded( onHighlightFinished = { highlightedNoteId.value = null }, groupPosition = watchChatGroupPosition(newer, item, older), previousNoteId = older?.idHex, - onWantsToEditBuzz = onWantsToEditBuzz, + onWantsToEditChatMessage = onWantsToEditChatMessage, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index df73e0d147..72170071c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -94,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.reactionBox import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet @@ -120,7 +122,7 @@ fun ChatMessageActionSheet( onDismiss: () -> Unit, accountViewModel: AccountViewModel, nav: INav, - onWantsToEditBuzz: ((Note) -> Unit)? = null, + onWantsToEditChatMessage: ((Note) -> Unit)? = null, ) { var showShareSheet by remember { mutableStateOf(false) } var wantsToEditPost by remember { mutableStateOf(false) } @@ -267,18 +269,25 @@ fun ChatMessageActionSheet( // Stage one: the primary chat action (reply / edit draft) is always shown. ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss) - // Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit). - // A 40002 event is inherently a Buzz message, so the type alone is the gate; - // authorship restricts it to my own messages. - val canEditBuzz = - onWantsToEditBuzz != null && - note.event is StreamMessageV2Event && - note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex - if (canEditBuzz) { + // Editing my own chat message. Two surfaces publish an edit today, gated by type: + // - Buzz: kind-40002 stream message → a kind-40003 edit. + // - Concord: kind-9 channel message (carries a ConcordChannel gatherer) → a + // kind-1010 edit wrapped on the channel plane. + // Both restrict to my own messages; a note is only ever one of the two, so at + // most one tile shows and both route through the same edit callback. + val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex + val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine + val canEditConcord = + onWantsToEditChatMessage != null && + note.event is ChatEvent && + isMine && + note.inGatherers?.any { it is ConcordChannel } == true + if (canEditBuzz || canEditConcord) { SectionDivider() TileRow { - ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) { - onWantsToEditBuzz!!(note) + val label = if (canEditBuzz) R.string.buzz_edit_message else R.string.edit_message + ActionTile(MaterialSymbols.Edit, stringRes(label)) { + onWantsToEditChatMessage!!(note) onDismiss() } } 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 60d4c023ed..dc3a8108fa 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 @@ -65,6 +65,7 @@ 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.RenderEncryptedFile import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegu import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeConcordEdit import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent @@ -114,9 +116,10 @@ fun ChatroomMessageCompose( // reply quotes inside a DM, where the target is simply older than the loaded window (see // LoadingReplyNote). Null keeps the default blank for every other caller. onBlank: (@Composable () -> Unit)? = null, - // Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for - // every non-Buzz chat surface, which hides the action. - onWantsToEditBuzz: ((Note) -> Unit)? = null, + // Edit my own chat message on surfaces that support it (Buzz kind-40002 → 40003, + // Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides + // the action. + onWantsToEditChatMessage: ((Note) -> Unit)? = null, ) { // Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted // chat message renders with the chat reply design instead of the quoted-note card. @@ -171,7 +174,7 @@ fun ChatroomMessageCompose( onHighlightFinished, groupPosition, previousNoteId, - onWantsToEditBuzz, + onWantsToEditChatMessage, ) } } @@ -202,7 +205,7 @@ fun NormalChatNote( onHighlightFinished: (() -> Unit)? = null, groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE, previousNoteId: HexKey? = null, - onWantsToEditBuzz: ((Note) -> Unit)? = null, + onWantsToEditChatMessage: ((Note) -> Unit)? = null, ) { // A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as // self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment, @@ -333,7 +336,7 @@ fun NormalChatNote( onDismiss = onDismiss, accountViewModel = accountViewModel, nav = nav, - onWantsToEditBuzz = onWantsToEditBuzz, + onWantsToEditChatMessage = onWantsToEditChatMessage, ) }, reactionsRow = @@ -612,14 +615,15 @@ fun NoteRow( note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav) hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav) else -> { - // Buzz channels overlay kind-40003 edits on their messages: when one - // exists, render the newest edit's content instead of the stale - // original. Null for every non-Buzz chat surface. + // Concord and Buzz channels overlay edits on their messages (kind-1010 and + // kind-40003 respectively): when one exists, render the newest edit's content + // instead of the stale original. Both are null for every other chat surface. + val concordEdit = observeConcordEdit(note) val buzzEdit = observeBuzzEdit(note) - if (buzzEdit != null) { - RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav) - } else { - RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav) + when { + concordEdit != null -> RenderConcordEditedNote(note, concordEdit, canPreview, innerQuote, bgColor, accountViewModel, nav) + buzzEdit != null -> RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav) + else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav) } } } 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/RenderConcordEdits.kt new file mode 100644 index 0000000000..d7babcf34d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt @@ -0,0 +1,133 @@ +/* + * 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.feed.types + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.sample + +/** + * Observes the newest kind-1010 edit overlaying a Concord chat message [note], + * recomposing when a new edit lands. Returns null for non-Concord messages or an + * unedited one. + * + * Concord edits ride the encrypted channel plane (unlike public feed edits, there is + * no relay subscription to start here — the session decrypts the wrap and lands the + * kind-1010 rumor in [LocalCache] itself). Resolution then goes through the same + * shared machinery the feed uses: [LocalCache.findLatestModificationForNote] keeps + * only same-author modifications, so a member can't rewrite someone else's message, + * and the newest one wins (last write). Runs off the main thread because + * `findLatestModificationForNote` scans the cache. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun observeConcordEdit(note: Note): Note? { + // Key on note.event, not note: LocalCache mutates a Note in place, so keying on the Note instance + // would cache a null gatherer taken before the event populated and never recompute for that row. + val isConcord = remember(note.event) { note.inGatherers?.any { it is ConcordChannel } == true } + if (!isConcord) return null + + val latest by + produceState(initialValue = null, note) { + note + .flow() + .edits + .stateFlow + .sample(500) + .mapLatest { LocalCache.findLatestModificationForNote(note).lastOrNull() } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + .collect { value = it } + } + return latest +} + +/** + * A Concord chat message whose content has been superseded by a kind-1010 edit: + * renders the NEWEST edit's content (never the stale original) plus an "(edited)" + * marker, matching the feed's last-write-wins edit presentation. + */ +@Composable +fun RenderConcordEditedNote( + note: Note, + editNote: Note, + canPreview: Boolean, + innerQuote: Boolean, + bgColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + // The edit note may still be loading; fall back to the original rendering rather + // than committing to an edited branch that would show a blank row. + val content = editNote.event?.content + if (content == null) { + RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav) + return + } + // Custom emoji + mentions live on the edit's own tags, so render against those. + val tags = remember(editNote.event) { editNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + Column { + TranslatableRichTextViewer( + content = content, + canPreview = canPreview, + quotesLeft = if (innerQuote) 0 else 1, + modifier = Modifier, + tags = tags, + backgroundColor = bgColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + authorPubKey = note.author?.pubkeyHex, + accountViewModel = accountViewModel, + nav = nav, + ) + Text( + text = stringRes(R.string.message_edited), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 764961ace3..f36f1eaa42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -194,6 +195,7 @@ fun ConcordChannelScreen( routeForLastRead = concordChannelLastReadRoute(communityId, channelId), onWantsToReply = { newMessageModel.reply(it) }, onWantsToEditDraft = {}, + onWantsToEditChatMessage = { newMessageModel.editConcordMessage(it) }, // A status card at the oldest end: shows what it's reaching for while it pages and // crossfades to "All caught up" when every relay runs dry. olderBoundary = { @@ -411,6 +413,35 @@ private fun ConcordMessageComposer( ) } + // Edit mode: a banner reminding the user the next send replaces this message (a kind-1010 + // edit on the channel plane), with an X to abandon the edit and clear the field. + newMessageModel.editingMessage.value?.let { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SymbolIcon( + symbol = MaterialSymbols.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(com.vitorpamplona.amethyst.R.string.concord_editing_banner), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + IconButton(onClick = { newMessageModel.cancelEdit() }) { + SymbolIcon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.cancel), + modifier = Modifier.size(16.dp), + ) + } + } + } + Column(modifier = EditFieldModifier) { newMessageModel.userSuggestions?.let { ShowUserSuggestionList( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index f104994923..5a37ff3e8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -63,6 +63,10 @@ open class ConcordNewMessageViewModel : ViewModel() { val message = TextFieldState() val replyTo = mutableStateOf(null) + // The message currently being edited (my own kind-9), or null for a fresh post/reply. When set, + // [sendPost] publishes a kind-1010 edit on the channel plane instead of a new message. + val editingMessage = mutableStateOf(null) + // How the pending reply is delivered: INLINE stays in the timeline (kind-9 quote), // MINICHAT pulls it into a thread (kind-1111). Only meaningful while replyTo is set. val replyMode = mutableStateOf(ReplyMode.INLINE) @@ -121,12 +125,26 @@ open class ConcordNewMessageViewModel : ViewModel() { this.channelId = channelId this.message.clearText() this.replyTo.value = null + this.editingMessage.value = null } } fun reply(note: Note) { replyTo.value = note replyMode.value = ReplyMode.INLINE + editingMessage.value = null + } + + /** Enter edit mode for my own [note]: prefills the field with its current text; sending publishes a kind-1010 edit. */ + fun editConcordMessage(note: Note) { + replyTo.value = null + editingMessage.value = note + message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "") + } + + fun cancelEdit() { + editingMessage.value = null + message.clearText() } /** Reply to [note] directly in a minichat thread (used from the minichat screen / long-press). */ @@ -184,8 +202,14 @@ open class ConcordNewMessageViewModel : ViewModel() { val text = message.text.toString().trim() if (text.isEmpty()) return - val parent = replyTo.value - account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) + val editing = editingMessage.value + if (editing != null) { + account.editConcordChannelMessage(editing, text) + editingMessage.value = null + } else { + val parent = replyTo.value + account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) + } message.clearText() clearReply() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index 7f347f3b1a..add5333fb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -217,7 +217,7 @@ private fun ChannelView( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, - onWantsToEditBuzz = newPostModel::editBuzzMessage, + onWantsToEditChatMessage = newPostModel::editBuzzMessage, jumpToNoteId = jumpToNoteId, onJumpHandled = { jumpToNoteId.value = null }, // A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d820e271eb..501ddfed1e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3326,6 +3326,9 @@ Canvas (Markdown) Edit Editing message + Edit + (edited) + Editing message %1$s is typing… %1$s and %2$s are typing… Several people are typing… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 4847685fa0..396492710a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -255,6 +255,25 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * Builds an encrypted-seal **edit** wrap (kind-1010 modification of [target]) on the [channel] + * plane. [newText] replaces [target]'s content on receivers that apply the native edit overlay; + * only the original author's edits take effect, so restrict callers to their own messages. + */ + suspend fun buildChannelEdit( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + target: Event, + newText: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event { + val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, extraTags) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ suspend fun buildChannelReaction( authorSigner: NostrSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 1752ab9172..c40a8f6a83 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord03Channels import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -102,6 +103,42 @@ object ChannelChat { extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags, ) + /** + * Builds an unsigned kind-1010 [TextNoteModificationEvent] rumor that edits an + * existing channel message [targetId] with [newText], bound to [channelId]/[epoch]. + * + * This reuses Amethyst's native feed-post edit event (kind 1010): the edit is a + * separate rumor pointing at the target via an `e` tag, wrapped and published on + * the same channel plane as any other Chat Plane rumor. On the receiving side it + * decrypts to a normal kind-1010 that the shared edit machinery + * (`LocalCache.findLatestModificationForNote`) overlays onto the target — last + * edit wins, and only edits authored by the *original* message's author are + * applied (enforced there), so a member can't rewrite someone else's message. A + * client that doesn't understand kind-1010 simply keeps showing the original + * text, so the edit degrades gracefully. + */ + fun edit( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + targetId: HexKey, + newText: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = TextNoteModificationEvent.KIND, + tags = + arrayOf( + ChannelTag.assemble(channelId), + EpochTag.assemble(epoch), + arrayOf("e", targetId), + ) + extraTags, + content = newText, + ) + /** * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to * [parent], bound to [channelId]/[epoch]. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index bf814a59a7..2e54b2a17f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -100,6 +100,31 @@ class ChannelChatEndToEndTest { assertTrue(ChannelChat.isBoundTo(thread, channelIdHex, 0L)) } + @Test + fun editIsAChannelBoundKind1010ThatPointsAtTheTargetAndRoundTrips() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + + val original = ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "helo", createdAt = 1L) + val edit = ChannelChat.edit(alice.pubKey, channelIdHex, rootEpoch, original.id, "hello", createdAt = 2L) + + // A native kind-1010 edit, e-tagging the original, still bound to the channel/epoch. + assertEquals(1010, edit.kind) + assertEquals(original.id, edit.tags.first { it[0] == "e" }[1]) + assertEquals("hello", edit.content) + assertTrue(ChannelChat.isBoundTo(edit, channelIdHex, rootEpoch)) + assertFalse(ChannelChat.isBoundTo(edit, channelIdHex, 1L)) // wrong epoch can't be replayed + + // Wraps + opens on the shared plane like any other Chat Plane rumor. + val wrap = ConcordStreamEnvelope.wrap(edit, channel, alice, encrypted = true) + val opened = ConcordStreamEnvelope.open(wrap, channel) + assertEquals(1010, opened.rumor.kind) + assertEquals("hello", opened.rumor.content) + assertEquals(alice.pubKey, opened.author) + assertEquals(original.id, opened.rumor.tags.first { it[0] == "e" }[1]) + } + @Test fun typingHeartbeatIsAnEphemeralWrapReadableByAnotherMember() = runTest { From 92915c9b1738ab6acc4760589fb7a5ab884ebaf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 00:58:39 +0000 Subject: [PATCH 02/11] fix(concord): use the dedicated kind-3302 edit, matching Armada's wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the Concord v2 reference client (Soapbox Armada, src/concord-v2/lib/kinds.ts): a chat message edit is a dedicated KIND_EDIT = 3302 rumor, NOT a kind-1010 modification. It names the target with a single `e` tag (no `k` — Armada adds `k` only to deletes), carries the replacement text, and rides the channel/epoch binding. The fold applies only edits authored by the original message's author (latest by CORD-02 §4 send time `created_at*1000 + ms`), non-destructively. The prior commit used kind-1010 TextNoteModificationEvent, which would not interop with Armada. Corrected: - New ConcordChatEditEvent (kind 3302) in quartz, registered in EventFactory; ChannelChat.edit now builds it. orderingMs() honors the `ms` remainder tag. - LocalCache.consume(ConcordChatEditEvent) wires the edit to its target note and invalidates the edits flow; findLatestConcordEditForNote returns the author-matching kind-3302 edits ordered by send time (latest wins). - observeConcordEdit reads that finder instead of the kind-1010 machinery. Send/compose/action-sheet plumbing is unchanged (it routes through ChannelChat.edit). Note: Amethyst does not yet emit the `ms` remainder tag on Concord rumors (a pre-existing, message-wide gap), so its own edits order at one-second granularity; received Armada edits are ordered at full precision. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../vitorpamplona/amethyst/model/Account.kt | 4 +- .../amethyst/model/LocalCache.kt | 75 +++++++++++++++++++ .../chats/feed/types/RenderConcordEdits.kt | 18 ++--- .../commons/actions/ConcordActions.kt | 4 +- .../concord/cord03Channels/ChannelChat.kt | 27 ++++--- .../cord03Channels/ConcordChatEditEvent.kt | 71 ++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../cord03Channels/ChannelChatEndToEndTest.kt | 16 ++-- 8 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 77ec0586e0..7a5f0495a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2354,9 +2354,9 @@ class Account( /** * Edit my own Concord channel message [note] to [newText]. Mirrors - * [reactToConcordMessage]: builds a kind-1010 [ChannelChat.edit] rumor bound to the + * [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays - * inside the encrypted channel (a public kind-1010 would e-tag the private rumor id onto + * inside the encrypted channel (a public edit would e-tag the private rumor id onto * public relays). The receiving side overlays the newest edit onto the target message; * only the *original author's* edits are applied, so we gate to my own kind-9 messages. * Returns false if [note] isn't an editable Concord message I authored. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3cb1277d9f..7602848e56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -135,6 +135,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -2791,6 +2792,43 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: ConcordChatEditEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return false + + // A Concord edit rumor is unsigned (its sig is empty); the envelope open path already + // established authenticity, so we consume it as pre-verified like any other Concord rumor. + if (wasVerified || justVerify(event)) { + note.loadEvent(event, author, emptyList()) + + event.editedMessageId()?.let { + checkGetOrCreateNote(it)?.let { editedNote -> + concordEditCache.remove(editedNote.idHex) + // Must invalidate so the chat bubble re-derives the latest edit overlay. + editedNote.flowSet?.edits?.invalidateData() + } + } + + refreshNewNoteObservers(note) + + return true + } + + return false + } + fun consume( event: PollResponseEvent, relay: NormalizedRelayUrl?, @@ -3244,6 +3282,39 @@ object LocalCache : ILocalCache, ICacheProvider { return newNotes } + val concordEditCache = LruCache>(20) + + /** + * Every Concord chat edit (kind 3302) targeting [note] that was authored by [note]'s own + * author — a member can't rewrite someone else's message — oldest first (createdAt, then id), + * so the caller applies the last as the winning edit. Mirrors [findLatestModificationForNote] + * but for the dedicated Concord edit kind rather than the kind-1010 feed edit. + */ + fun findLatestConcordEditForNote(note: Note): List { + checkNotInMainThread() + + val noteAuthor = note.author ?: return emptyList() + + concordEditCache[note.idHex]?.let { + return it + } + + val newNotes = + notes + .filter { _, item -> + val noteEvent = item.event + + noteEvent is ConcordChatEditEvent && noteAuthor == item.author && noteEvent.editedMessageId() == note.idHex + } + // Order by CORD-02 §4 send time (createdAt*1000 + `ms` tag) so the caller's last is + // the winning edit, matching the reference client at sub-second precision. + .sortedWith(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex })) + + concordEditCache.put(note.idHex, newNotes) + + return newNotes + } + fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() @@ -4914,6 +4985,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is ConcordChatEditEvent -> { + consume(event, relay, wasVerified) + } + is TorrentEvent -> { consumeRegularEvent(event, relay, wasVerified) } 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/RenderConcordEdits.kt index d7babcf34d..7a95e8f861 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/RenderConcordEdits.kt @@ -49,17 +49,17 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.sample /** - * Observes the newest kind-1010 edit overlaying a Concord chat message [note], + * Observes the newest kind-3302 Concord edit overlaying a Concord chat message [note], * recomposing when a new edit lands. Returns null for non-Concord messages or an * unedited one. * * Concord edits ride the encrypted channel plane (unlike public feed edits, there is * no relay subscription to start here — the session decrypts the wrap and lands the - * kind-1010 rumor in [LocalCache] itself). Resolution then goes through the same - * shared machinery the feed uses: [LocalCache.findLatestModificationForNote] keeps - * only same-author modifications, so a member can't rewrite someone else's message, - * and the newest one wins (last write). Runs off the main thread because - * `findLatestModificationForNote` scans the cache. + * kind-3302 rumor in [LocalCache] itself). Resolution goes through + * [LocalCache.findLatestConcordEditForNote], which keeps only edits authored by the + * original message's author — so a member can't rewrite someone else's message — and + * the newest one wins (last write). Runs off the main thread because the finder scans + * the cache. */ @OptIn(ExperimentalCoroutinesApi::class) @Composable @@ -76,7 +76,7 @@ fun observeConcordEdit(note: Note): Note? { .edits .stateFlow .sample(500) - .mapLatest { LocalCache.findLatestModificationForNote(note).lastOrNull() } + .mapLatest { LocalCache.findLatestConcordEditForNote(note).lastOrNull() } .distinctUntilChanged() .flowOn(Dispatchers.IO) .collect { value = it } @@ -85,9 +85,9 @@ fun observeConcordEdit(note: Note): Note? { } /** - * A Concord chat message whose content has been superseded by a kind-1010 edit: + * 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 feed's last-write-wins edit presentation. + * marker, matching the Concord reference client's last-write-wins presentation. */ @Composable fun RenderConcordEditedNote( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 396492710a..378837e5bf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -256,8 +256,8 @@ object ConcordActions { } /** - * Builds an encrypted-seal **edit** wrap (kind-1010 modification of [target]) on the [channel] - * plane. [newText] replaces [target]'s content on receivers that apply the native edit overlay; + * Builds an encrypted-seal **edit** wrap (kind-3302 [ChannelChat.edit] of [target]) on the + * [channel] plane. [newText] replaces [target]'s content on receivers that apply the edit overlay; * only the original author's edits take effect, so restrict callers to their own messages. */ suspend fun buildChannelEdit( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index c40a8f6a83..df5e98a0e5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.concord.cord03Channels import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag -import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -104,18 +103,18 @@ object ChannelChat { ) /** - * Builds an unsigned kind-1010 [TextNoteModificationEvent] rumor that edits an - * existing channel message [targetId] with [newText], bound to [channelId]/[epoch]. + * Builds an unsigned kind-3302 [ConcordChatEditEvent] rumor that edits an existing + * channel message [targetId] with [newText], bound to [channelId]/[epoch]. * - * This reuses Amethyst's native feed-post edit event (kind 1010): the edit is a - * separate rumor pointing at the target via an `e` tag, wrapped and published on - * the same channel plane as any other Chat Plane rumor. On the receiving side it - * decrypts to a normal kind-1010 that the shared edit machinery - * (`LocalCache.findLatestModificationForNote`) overlays onto the target — last - * edit wins, and only edits authored by the *original* message's author are - * applied (enforced there), so a member can't rewrite someone else's message. A - * client that doesn't understand kind-1010 simply keeps showing the original - * text, so the edit degrades gracefully. + * This is the dedicated Concord edit kind (CORD-02 Appendix B) the reference client + * (Soapbox Armada's `KIND_EDIT`) emits — a separate rumor naming the target with a + * single `["e", …]` tag, carrying the replacement text, wrapped and published on the + * same channel plane as any other Chat Plane rumor. Armada omits a `k` tag on edits + * (only deletes carry one), so we do too, for wire parity. On the receiving side it + * decrypts to a kind-3302 that the fold overlays onto the target — latest edit wins, + * and only edits authored by the *original* message's author are applied, so a member + * can't rewrite someone else's message. It's non-destructive: the original keeps its + * id, so reactions/replies/quotes stay attached. */ fun edit( authorPubKey: HexKey, @@ -126,10 +125,10 @@ object ChannelChat { createdAt: Long, extraTags: Array> = emptyArray(), ): Event = - RumorAssembler.assembleRumor( + RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, - kind = TextNoteModificationEvent.KIND, + kind = ConcordChatEditEvent.KIND, tags = arrayOf( ChannelTag.assemble(channelId), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt new file mode 100644 index 0000000000..40cd7bbd8c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt @@ -0,0 +1,71 @@ +/* + * 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.quartz.concord.cord03Channels + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent + +/** + * A Concord Chat Plane **message edit** (CORD-02 Appendix B, `kind:3302`). + * + * A dedicated edit rumor — NOT a kind-1010 modification and NOT a delete + + * republish — matching the Concord v2 reference client (Soapbox Armada's + * `KIND_EDIT`, `src/concord-v2/lib/kinds.ts`). It names the target message with a + * single `["e", ]` tag and carries the replacement text as its content; the + * usual channel/epoch binding tags scope it to its plane. Receivers overlay the + * newest edit **authored by the original message's author** onto that message + * (latest wins), non-destructively — the original keeps its id, so reactions, + * replies, and quotes stay attached (see `LocalCache.findLatestConcordEditForNote`). + */ +@Immutable +class ConcordChatEditEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The id of the message this edit replaces (its `e` tag), or null if malformed. */ + fun editedMessageId(): HexKey? = firstTaggedEvent()?.eventId + + /** + * The full-precision send time in epoch-milliseconds: `createdAt * 1000` plus the `["ms", <0..999>]` + * remainder tag (CORD-02 §4). Used to order competing edits at sub-second precision, matching the + * reference client (an absent/malformed `ms` tag reads as 0). "Latest edit wins" compares this. + */ + fun orderingMs(): Long { + val remainder = + tags + .firstOrNull { it.size > 1 && it[0] == "ms" } + ?.get(1) + ?.toIntOrNull() + ?.takeIf { it in 0..999 } + ?: 0 + return createdAt * 1000 + remainder + } + + companion object { + const val KIND = 3302 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index a32e142e62..9579900c2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -98,6 +98,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent @@ -423,6 +424,7 @@ class EventFactory { ): T = when (kind) { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) + ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig) AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) AgentTurnMetricEvent.KIND -> AgentTurnMetricEvent(id, pubKey, createdAt, tags, content, sig) EngramEvent.KIND -> EngramEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index 2e54b2a17f..8c65b9e47a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -101,7 +101,7 @@ class ChannelChatEndToEndTest { } @Test - fun editIsAChannelBoundKind1010ThatPointsAtTheTargetAndRoundTrips() = + fun editIsAChannelBoundKind3302ThatPointsAtTheTargetAndRoundTrips() = runTest { val alice = NostrSignerInternal(KeyPair()) val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) @@ -109,20 +109,24 @@ class ChannelChatEndToEndTest { val original = ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "helo", createdAt = 1L) val edit = ChannelChat.edit(alice.pubKey, channelIdHex, rootEpoch, original.id, "hello", createdAt = 2L) - // A native kind-1010 edit, e-tagging the original, still bound to the channel/epoch. - assertEquals(1010, edit.kind) + // The dedicated Concord edit kind (Armada KIND_EDIT), e-tagging the original, + // still bound to the channel/epoch. Armada omits a `k` tag on edits, so we do too. + assertEquals(ConcordChatEditEvent.KIND, edit.kind) + assertEquals(3302, edit.kind) assertEquals(original.id, edit.tags.first { it[0] == "e" }[1]) + assertTrue(edit.tags.none { it[0] == "k" }) assertEquals("hello", edit.content) assertTrue(ChannelChat.isBoundTo(edit, channelIdHex, rootEpoch)) assertFalse(ChannelChat.isBoundTo(edit, channelIdHex, 1L)) // wrong epoch can't be replayed - // Wraps + opens on the shared plane like any other Chat Plane rumor. + // Wraps + opens on the shared plane like any other Chat Plane rumor; the opened + // rumor is typed as a ConcordChatEditEvent that names the edited message. val wrap = ConcordStreamEnvelope.wrap(edit, channel, alice, encrypted = true) val opened = ConcordStreamEnvelope.open(wrap, channel) - assertEquals(1010, opened.rumor.kind) + assertEquals(3302, opened.rumor.kind) assertEquals("hello", opened.rumor.content) assertEquals(alice.pubKey, opened.author) - assertEquals(original.id, opened.rumor.tags.first { it[0] == "e" }[1]) + assertEquals(original.id, (opened.rumor as ConcordChatEditEvent).editedMessageId()) } @Test From 1881bc919a3a44612f76934f1263cf3a06ee4a64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 02:10:50 +0000 Subject: [PATCH 03/11] refactor(concord): observe edits via LocalCache.observeEvents Watch kind-3302 edits reactively through LocalCache.observeEvents, narrowed on the edit's `e` tag, instead of a whole-cache scan wired to the target note's edits flow. The index-backed observer seeds from any edit already cached and wakes on each new one, matching how the app observes reactions, Nest presence, and git PR updates. - observeConcordEdit now collects observeEvents({kinds, "#e"}); it filters to the original author and takes the latest by send time. - consume(ConcordChatEditEvent) drops the manual edits-flow invalidation and just lands the event + wakes observers (refreshNewNoteObservers). - Removed the now-unused findLatestConcordEditForNote scan and concordEditCache. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../amethyst/model/LocalCache.kt | 44 ++----------------- .../chats/feed/types/RenderConcordEdits.kt | 44 +++++++++---------- 2 files changed, 25 insertions(+), 63 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 7602848e56..ac0e199acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2813,14 +2813,9 @@ object LocalCache : ILocalCache, ICacheProvider { if (wasVerified || justVerify(event)) { note.loadEvent(event, author, emptyList()) - event.editedMessageId()?.let { - checkGetOrCreateNote(it)?.let { editedNote -> - concordEditCache.remove(editedNote.idHex) - // Must invalidate so the chat bubble re-derives the latest edit overlay. - editedNote.flowSet?.edits?.invalidateData() - } - } - + // The bubble watches for these reactively via LocalCache.observeEvents narrowed on the + // edit's `e` tag, so simply landing it in the cache + waking observers is enough — the + // overlay re-derives without any target-note bookkeeping here. refreshNewNoteObservers(note) return true @@ -3282,39 +3277,6 @@ object LocalCache : ILocalCache, ICacheProvider { return newNotes } - val concordEditCache = LruCache>(20) - - /** - * Every Concord chat edit (kind 3302) targeting [note] that was authored by [note]'s own - * author — a member can't rewrite someone else's message — oldest first (createdAt, then id), - * so the caller applies the last as the winning edit. Mirrors [findLatestModificationForNote] - * but for the dedicated Concord edit kind rather than the kind-1010 feed edit. - */ - fun findLatestConcordEditForNote(note: Note): List { - checkNotInMainThread() - - val noteAuthor = note.author ?: return emptyList() - - concordEditCache[note.idHex]?.let { - return it - } - - val newNotes = - notes - .filter { _, item -> - val noteEvent = item.event - - noteEvent is ConcordChatEditEvent && noteAuthor == item.author && noteEvent.editedMessageId() == note.idHex - } - // Order by CORD-02 §4 send time (createdAt*1000 + `ms` tag) so the caller's last is - // the winning edit, matching the reference client at sub-second precision. - .sortedWith(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex })) - - concordEditCache.put(note.idHex, newNotes) - - return newNotes - } - fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() 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/RenderConcordEdits.kt index 7a95e8f861..71407bbdf1 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/RenderConcordEdits.kt @@ -41,47 +41,47 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.sample /** * Observes the newest kind-3302 Concord edit overlaying a Concord chat message [note], * recomposing when a new edit lands. Returns null for non-Concord messages or an * unedited one. * - * Concord edits ride the encrypted channel plane (unlike public feed edits, there is - * no relay subscription to start here — the session decrypts the wrap and lands the - * kind-3302 rumor in [LocalCache] itself). Resolution goes through - * [LocalCache.findLatestConcordEditForNote], which keeps only edits authored by the - * original message's author — so a member can't rewrite someone else's message — and - * the newest one wins (last write). Runs off the main thread because the finder scans - * the cache. + * Concord edits ride the encrypted channel plane (unlike public feed edits, there is no + * relay subscription to start — the session decrypts the wrap and lands the kind-3302 + * rumor in [LocalCache] itself). We watch the cache reactively through + * [LocalCache.observeEvents], narrowed on the `e` tag so it seeds from any edit already + * cached and wakes on each new one without scanning the whole store. Only edits authored + * by the original message's author are applied — so a member can't rewrite someone else's + * message — and the latest by CORD-02 §4 send time wins. */ -@OptIn(ExperimentalCoroutinesApi::class) @Composable fun observeConcordEdit(note: Note): Note? { // Key on note.event, not note: LocalCache mutates a Note in place, so keying on the Note instance // would cache a null gatherer taken before the event populated and never recompute for that row. val isConcord = remember(note.event) { note.inGatherers?.any { it is ConcordChannel } == true } if (!isConcord) return null + val authorHex = note.author?.pubkeyHex ?: return null - val latest by - produceState(initialValue = null, note) { - note - .flow() - .edits - .stateFlow - .sample(500) - .mapLatest { LocalCache.findLatestConcordEditForNote(note).lastOrNull() } - .distinctUntilChanged() + val edits by + produceState>(emptyList(), note.idHex) { + val filter = Filter(kinds = listOf(ConcordChatEditEvent.KIND), tags = mapOf("e" to listOf(note.idHex))) + LocalCache + .observeEvents(filter) .flowOn(Dispatchers.IO) .collect { value = it } } - return latest + + return remember(edits, authorHex) { + edits + .filter { it.pubKey == authorHex } + .maxWithOrNull(compareBy({ it.orderingMs() }, { it.id })) + ?.let { LocalCache.getNoteIfExists(it.id) } + } } /** From 2213031f9fcdbc83c0bd06a8543293f16a27e791 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:48:29 +0000 Subject: [PATCH 04/11] fix(concord): anchor edits to their message so they survive cache eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalCache.notes is a soft cache, and a Concord rumor is decrypted exactly once per session (the community session dedups re-delivered wraps), so a kind-3302 edit left orphaned there could be GC'd on navigation and never re-downloaded — the message would silently revert to its pre-edit text. Resetting the channel EOSE doesn't help: the re-delivered wrap is swallowed by the session's isNew dedup, so its rumor never re-emits. Fix it the way reactions/replies already survive: attach the edit to the message it edits. consume(ConcordChatEditEvent) now calls target.addEdit(note), so the edit is held for exactly as long as its channel-retained message (and released with it via clearChildLinks). observeConcordEdit reads note.edits directly — author-matching, latest by CORD-02 §4 send time — instead of scanning/observing the soft cache. - Note: new hard-held `edits` collection + addEdit/removeEdit, wired into clearChildLinks like the other child-event links. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../amethyst/model/LocalCache.kt | 11 ++++-- .../chats/feed/types/RenderConcordEdits.kt | 39 ++++++++----------- .../amethyst/commons/model/Note.kt | 29 ++++++++++++++ 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ac0e199acc..2e7f3230d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2813,9 +2813,14 @@ object LocalCache : ILocalCache, ICacheProvider { if (wasVerified || justVerify(event)) { note.loadEvent(event, author, emptyList()) - // The bubble watches for these reactively via LocalCache.observeEvents narrowed on the - // edit's `e` tag, so simply landing it in the cache + waking observers is enough — the - // overlay re-derives without any target-note bookkeeping here. + // Anchor the edit to the message it edits (like a reaction to its target), so it survives + // as long as that channel-retained message does. A Concord rumor is decrypted exactly once + // per session — the community session dedups re-delivered wraps — so an edit left orphaned + // in the soft cache could be GC'd and never re-downloaded. The bubble reads `note.edits`. + event.editedMessageId()?.let { targetId -> + getOrCreateNote(targetId).addEdit(note) + } + refreshNewNoteObservers(note) return true 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/RenderConcordEdits.kt index 71407bbdf1..8436f7554d 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/RenderConcordEdits.kt @@ -42,9 +42,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOn /** * Observes the newest kind-3302 Concord edit overlaying a Concord chat message [note], @@ -53,11 +50,12 @@ import kotlinx.coroutines.flow.flowOn * * Concord edits ride the encrypted channel plane (unlike public feed edits, there is no * relay subscription to start — the session decrypts the wrap and lands the kind-3302 - * rumor in [LocalCache] itself). We watch the cache reactively through - * [LocalCache.observeEvents], narrowed on the `e` tag so it seeds from any edit already - * cached and wakes on each new one without scanning the whole store. Only edits authored - * by the original message's author are applied — so a member can't rewrite someone else's - * message — and the latest by CORD-02 §4 send time wins. + * rumor in [LocalCache] itself). Each edit is attached to the message it edits ([Note.edits], + * like a reaction to its target), so it is held for exactly as long as the channel-retained + * message — a Concord rumor is decrypted once per session and can't be re-downloaded, so it + * must not be left orphaned in the soft cache. We recompute from that list whenever it changes. + * Only edits authored by the original message's author are applied — so a member can't rewrite + * someone else's message — and the latest by CORD-02 §4 send time wins. */ @Composable fun observeConcordEdit(note: Note): Note? { @@ -67,21 +65,18 @@ fun observeConcordEdit(note: Note): Note? { if (!isConcord) return null val authorHex = note.author?.pubkeyHex ?: return null - val edits by - produceState>(emptyList(), note.idHex) { - val filter = Filter(kinds = listOf(ConcordChatEditEvent.KIND), tags = mapOf("e" to listOf(note.idHex))) - LocalCache - .observeEvents(filter) - .flowOn(Dispatchers.IO) - .collect { value = it } + // `addEdit` invalidates this flow, so collecting it re-runs the fold below on each new edit. + val latest by + produceState(initialValue = null, note.idHex) { + note.flow().edits.stateFlow.collect { + value = + note.edits + .filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent } + .maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex })) + ?.takeIf { it.event != null } + } } - - return remember(edits, authorHex) { - edits - .filter { it.pubKey == authorHex } - .maxWithOrNull(compareBy({ it.orderingMs() }, { it.id })) - ?.let { LocalCache.getNoteIfExists(it.id) } - } + return latest } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 802ba935b7..9eb40f6176 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -187,6 +187,17 @@ open class Note( var boosts = listOf() private set + /** + * Concord chat edits (kind 3302) targeting this message, held here — like [reactions] and + * [replies] — so an edit survives exactly as long as its message does. Concord decrypts each + * wrap's rumor only once per session (the community session dedups re-delivered wraps), so an + * edit evicted from the soft event cache can never be re-downloaded; anchoring it to the + * (channel-retained) message keeps it strongly reachable. The chat bubble overlays the latest + * author-matching edit. + */ + var edits = listOf() + private set + var reports = mapOf>() private set @@ -398,6 +409,20 @@ open class Note( } } + fun addEdit(note: Note) { + if (note !in edits) { + edits = edits + note + flowSet?.edits?.invalidateData() + } + } + + fun removeEdit(note: Note) { + if (note in edits) { + edits = edits - note + flowSet?.edits?.invalidateData() + } + } + fun removeBoost(note: Note) { if (note in boosts) { boosts = boosts - note @@ -410,6 +435,7 @@ open class Note( val reactionsChanged = reactions.isNotEmpty() val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() val boostsChanged = boosts.isNotEmpty() + val editsChanged = edits.isNotEmpty() val reportsChanged = reports.isNotEmpty() val labelsChanged = labels.isNotEmpty() @@ -417,6 +443,7 @@ open class Note( replies + reactions.values.flatten() + boosts + + edits + reports.values.flatten() + labels.values.flatten() + zaps.keys + @@ -429,6 +456,7 @@ open class Note( replies = listOf() reactions = mapOf() boosts = listOf() + edits = listOf() reports = mapOf() labels = mapOf() zaps = mapOf() @@ -442,6 +470,7 @@ open class Note( if (repliesChanged) flowSet?.replies?.invalidateData() if (reactionsChanged) flowSet?.reactions?.invalidateData() if (boostsChanged) flowSet?.boosts?.invalidateData() + if (editsChanged) flowSet?.edits?.invalidateData() if (reportsChanged) flowSet?.reports?.invalidateData() if (labelsChanged) flowSet?.labels?.invalidateData() if (zapsChanged) flowSet?.zaps?.invalidateData() From 98d8f88c0a02c216e06c0736b08962156fc0a9eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 17:39:03 +0000 Subject: [PATCH 05/11] refactor: unify all message edits onto Note.edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three edit kinds now anchor on the message they edit via the same Note.edits collection, instead of each maintaining its own store: - Feed edits (kind 1010): consume(TextNoteModificationEvent) calls editedNote.addEdit(note); findLatestModificationForNote folds note.edits (author-only, NIP-40 expiry) instead of scanning the whole cache. Drops the O(all-notes) scan and the 20-entry modificationCache LRU; cachedModificationEventsForNote is now synchronous (no Loading state). - Buzz edits (kind 40003): consume(StreamMessageEditEvent) calls target.addEdit(note); observeBuzzEdit reads note.edits (newest by created_at, no author gate — Buzz's own rule). Removes the channel-keyed BuzzWorkspaceState edit store, its editUpdates/editFor/effectiveContentFor/ addEdit and the pruneEdits reaping (edits now prune with their message). - Concord edits (kind 3302): already on note.edits. Each reader keeps its own semantics by filtering note.edits on its event type; the shared field only unifies storage + lifecycle, so an edit lives exactly as long as the message it edits. Buzz edit tests rewritten against note.edits (6/6 green). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../amethyst/model/LocalCache.kt | 61 +++++++------------ .../amethyst/ui/note/NoteCompose.kt | 16 +++-- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../chats/feed/types/RenderBuzzNotes.kt | 32 +++++----- .../model/BuzzWorkspaceChannelTest.kt | 34 ++++++----- .../commons/model/buzz/BuzzWorkspaceStates.kt | 45 ++------------ 6 files changed, 70 insertions(+), 120 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2e7f3230d4..019571d0f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model -import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex @@ -2241,15 +2240,14 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean = // Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an - // earlier message's content, never a row of its own. Store it and record the - // overlay (keyed by the channel's UUID, so own sends with no provenance relay - // land too), but do NOT attach it to the timeline. + // earlier message's content, never a row of its own. Store it and anchor it to the + // message it edits (Note.edits) — like every other edit kind — so the overlay is held + // for as long as its message and never leaks into the timeline as a bubble. consumeBuzzRegularEvent(event, relay, wasVerified).also { val target = event.editedMessage() ?: return@also - val channelId = event.channel() ?: return@also val editNote = getOrCreateNote(event.id) if (editNote.event != null) { - BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote) + getOrCreateNote(target).addEdit(editNote) } } @@ -2776,12 +2774,11 @@ object LocalCache : ILocalCache, ICacheProvider { if (wasVerified || justVerify(event)) { note.loadEvent(event, author, emptyList()) + // Anchor the modification to the note it edits, like every other edit kind — the read side + // ([findLatestModificationForNote]) then folds `edited.edits` instead of scanning the cache, + // and addEdit invalidates the note's edits flow so the UI re-derives. event.editedNote()?.let { - checkGetOrCreateNote(it.eventId)?.let { editedNote -> - modificationCache.remove(editedNote.idHex) - // must update list of Notes to quickly update the user. - editedNote.flowSet?.edits?.invalidateData() - } + checkGetOrCreateNote(it.eventId)?.addEdit(note) } refreshNewNoteObservers(note) @@ -3254,34 +3251,25 @@ object LocalCache : ILocalCache, ICacheProvider { return minTime } - val modificationCache = LruCache>(20) - - fun cachedModificationEventsForNote(note: Note): List? = modificationCache[note.idHex] - + /** + * The NIP-1010 edits of [note] to apply, oldest first — folded from the note's own + * [Note.edits] (where [consume] anchors each modification) rather than scanned from the + * whole cache. Only the original author's edits count, and expired (NIP-40) ones are + * dropped. Cheap (bounded by this note's edits), so it's safe to call from any thread. + */ fun findLatestModificationForNote(note: Note): List { - checkNotInMainThread() - val noteAuthor = note.author ?: return emptyList() - - modificationCache[note.idHex]?.let { - return it - } - val time = TimeUtils.now() - val newNotes = - notes - .filter { _, item -> - val noteEvent = item.event - - noteEvent is TextNoteModificationEvent && noteAuthor == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) - }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - - modificationCache.put(note.idHex, newNotes) - - return newNotes + return note.edits + .filter { item -> + val noteEvent = item.event + noteEvent is TextNoteModificationEvent && noteAuthor == item.author && !noteEvent.isExpirationBefore(time) + }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) } + fun cachedModificationEventsForNote(note: Note): List = findLatestModificationForNote(note) + fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() @@ -3372,13 +3360,6 @@ object LocalCache : ILocalCache, ICacheProvider { channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS) } - // A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside - // `notes`, so the top-N reap never touches it. Drop overlay entries whose target - // message was just pruned, else they pin the edit note + author forever. - if (channel is RelayGroupChannel) { - BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys()) - } - if (toBeRemoved.size > 100 || channel.notes.size() > 100) { println( "PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 7211be222f..b7d1b0a352 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -2060,18 +2060,16 @@ fun observeEdits( val editState = remember(baseNote.idHex) { + // Edits are anchored on the note (Note.edits), so the current set is readable synchronously + // (no cache scan) — start Empty or Loaded, never Loading. val cached = accountViewModel.cachedModificationEventsForNote(baseNote) mutableStateOf( - if (cached != null) { - if (cached.isEmpty()) { - GenericLoadable.Empty() - } else { - val state = EditState() - state.updateModifications(cached) - GenericLoadable.Loaded(state) - } + if (cached.isEmpty()) { + GenericLoadable.Empty() } else { - GenericLoadable.Loading() + val state = EditState() + state.updateModifications(cached) + GenericLoadable.Loaded(state) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index ede2817c11..8bc5440009 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2037,7 +2037,7 @@ class AccountViewModel( fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) - fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note) + fun cachedModificationEventsForNote(note: Note): List = LocalCache.cachedModificationEventsForNote(note) fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index 2d10c310c4..bbe3986ef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -30,8 +30,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -41,7 +41,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -61,28 +60,31 @@ import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent 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.nip01Core.core.Event -import com.vitorpamplona.quartz.nip29RelayGroups.groupId /** * Observes the newest kind-40003 edit overlaying [note], recomposing when new edits - * arrive. Returns null when the message is unedited or has no channel scope. + * arrive. Returns null when the message is unedited. * - * Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id - * (a Buzz UUID) rather than any channel object: the state exists independently of - * when — or whether — the channel materialized, so a row composed before the first - * edit arrived still starts rendering overlays the moment one lands. + * Each edit is anchored on the message it edits ([Note.edits], where [LocalCache] consumes it), + * so it is held for as long as its message and read straight off the note — no channel-keyed + * side store. Buzz keeps the newest by `created_at` regardless of author (its own last-write-wins + * rule); [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold. */ @Composable fun observeBuzzEdit(note: Note): Note? { - // Key on note.event, not note: LocalCache mutates a Note in place, so keying on the Note instance - // would cache a null groupId taken before the event populated and never recompute for that row. - val channelId = remember(note.event) { note.event?.groupId() } ?: return null - val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) } - // Subscribing to the version counter is what re-runs editFor on new arrivals. - val version by state.editUpdates.collectAsState() - return remember(note, version) { state.editFor(note.idHex) } + val latest by + produceState(initialValue = null, note.idHex) { + note.flow().edits.stateFlow.collect { + value = + note.edits + .filter { it.event is StreamMessageEditEvent } + .maxByOrNull { it.createdAt() ?: 0L } + } + } + return latest } /** diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 3b149f1359..173b889dc4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -39,7 +39,6 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -48,7 +47,7 @@ import java.util.UUID /** * The Buzz dialect of NIP-29 in `LocalCache`: dialect detection off VERIFIED events, * timeline attachment into the group's (stable, never-swapped) `RelayGroupChannel`, and - * the kind-40003 edit overlay held in `BuzzWorkspaceStates` keyed by the channel id. + * the kind-40003 edit overlay anchored on the message it edits (`Note.edits`). */ class BuzzWorkspaceChannelTest { private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!! @@ -147,9 +146,11 @@ class BuzzWorkspaceChannelTest { LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false) LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false) - val state = BuzzWorkspaceStates.getIfExists(channelId)!! - assertEquals("newest edit wins regardless of arrival order", "the fix", state.effectiveContentFor(original.id)) - assertEquals(edit2.id, state.editFor(original.id)?.idHex) + // The edits are anchored on the message they edit (Note.edits); newest by created_at wins. + val target = LocalCache.getNoteIfExists(original.id)!! + val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L } + assertEquals("newest edit wins regardless of arrival order", "the fix", newest?.event?.content) + assertEquals(edit2.id, newest?.idHex) val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))!! assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit1.id)) @@ -157,10 +158,10 @@ class BuzzWorkspaceChannelTest { } @Test - fun overlayIsKeyedByChannelIdSoOwnSendsWithNoRelayLand() = + fun ownSendsWithNoRelayStillOverlayTheirMessage() = runBlocking { - // An edit consumed with a null provenance relay (own optimistic send) must - // still record its overlay — the registry is keyed by channel id, not relay. + // An edit consumed with a null provenance relay (own optimistic send) must still + // overlay its message — it is anchored on the message note, independent of any relay. val channelId = newChannelId() val original = streamMessage(channelId, "original") LocalCache.checkDeletionAndConsume(original, null, true) @@ -171,11 +172,13 @@ class BuzzWorkspaceChannelTest { ) LocalCache.checkDeletionAndConsume(edit, null, true) - assertEquals("edited offline", BuzzWorkspaceStates.getIfExists(channelId)?.effectiveContentFor(original.id)) + val target = LocalCache.getNoteIfExists(original.id)!! + val newest = target.edits.filter { it.event is StreamMessageEditEvent }.maxByOrNull { it.createdAt() ?: 0L } + assertEquals("edited offline", newest?.event?.content) } @Test - fun pruneDropsOverlaysForMessagesNoLongerInTheChannel() = + fun pruningAMessageReleasesItsEdits() = runBlocking { val channelId = newChannelId() val original = streamMessage(channelId, "will be pruned") @@ -183,11 +186,12 @@ class BuzzWorkspaceChannelTest { val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "edit", createdAt = original.createdAt + 5)) LocalCache.checkDeletionAndConsume(edit, buzzRelay, false) - val state = BuzzWorkspaceStates.getIfExists(channelId)!! - assertNotNull(state.editFor(original.id)) + val target = LocalCache.getNoteIfExists(original.id)!! + assertTrue("the edit is anchored on its message", target.edits.any { it.idHex == edit.id }) - // Simulate the message having been reaped from the channel. - state.pruneEdits(emptySet()) - assertNull("overlay for a pruned message must be dropped", state.editFor(original.id)) + // An edit lives in Note.edits, so reaping the message releases the overlay with it — + // no separate side store to prune. + target.clearChildLinks() + assertTrue("overlay for a pruned message must be dropped", target.edits.isEmpty()) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt index 5ef916c889..9876820844 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt @@ -23,16 +23,15 @@ package com.vitorpamplona.amethyst.commons.model.buzz import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlin.concurrent.Volatile /** - * Buzz-only overlay state for one workspace channel: the kind-40003 edit overlay - * (newest edit per message — rendering the original without it shows stale text as - * current) and the newest kind-40100 canvas. + * Buzz-only overlay state for one workspace channel: the newest kind-40100 canvas. + * (Kind-40003 message edits are no longer tracked here — like every other edit kind + * they are anchored on the message they edit via `Note.edits`.) * * This lives OUTSIDE the channel object on purpose. Screens, feed filters, and * composers capture their `RelayGroupChannel` instance once and hold it for the whole @@ -41,17 +40,11 @@ import kotlin.concurrent.Volatile * orphaned instance. Keeping the overlay in a registry keyed by the channel id makes * dialect discovery a non-event for object identity. * - * All mutations are guarded by a per-state lock: consume runs on multiple relay - * dispatcher threads, and unsynchronized check-then-act would let an older edit - * overwrite a newer one. + * The mutation is guarded by a per-state lock: consume runs on multiple relay dispatcher + * threads, and unsynchronized check-then-act would let an older canvas overwrite a newer one. */ class BuzzWorkspaceState { private val lock = KmpLock() - private val editsByTarget = LargeCache() - private val editVersion = MutableStateFlow(0) - - /** Bumps when any overlay entry changes, so rows re-read [editFor]. */ - val editUpdates: StateFlow = editVersion /** The newest canvas (kind 40100) note for this channel, or null when none seen. */ @Volatile @@ -63,24 +56,6 @@ class BuzzWorkspaceState { /** Bumps when [canvasNote] is replaced by a newer revision, so a canvas view re-reads it. */ val canvasUpdates: StateFlow = canvasVersion - /** Records a 40003 edit; keeps only the newest per target (last-write-wins by created_at). */ - fun addEdit( - targetId: HexKey, - editNote: Note, - ) = lock.withLock { - val current = editsByTarget.get(targetId) - if (current == null || (editNote.createdAt() ?: 0L) > (current.createdAt() ?: 0L)) { - editsByTarget.put(targetId, editNote) - editVersion.value = editVersion.value + 1 - } - } - - /** The newest edit note overlaying [targetId], or null when the message is unedited. */ - fun editFor(targetId: HexKey): Note? = editsByTarget.get(targetId) - - /** The effective display content for a message: its newest edit's text, or null when unedited. */ - fun effectiveContentFor(targetId: HexKey): String? = editsByTarget.get(targetId)?.event?.content - fun updateCanvas(note: Note) = lock.withLock { if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) { @@ -88,16 +63,6 @@ class BuzzWorkspaceState { canvasVersion.value = canvasVersion.value + 1 } } - - /** Drops overlay entries whose target message id is not in [aliveTargetIds] (memory pruning). */ - fun pruneEdits(aliveTargetIds: Set) = - lock.withLock { - val dead = editsByTarget.keys().filter { it !in aliveTargetIds } - if (dead.isNotEmpty()) { - dead.forEach { editsByTarget.remove(it) } - editVersion.value = editVersion.value + 1 - } - } } /** From 8dcee46cdf6be45ece0643267257f7af4fadf851 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 17:54:23 +0000 Subject: [PATCH 06/11] fix(buzz): only the author's own kind-40003 edit may rewrite a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Buzz edit overlay applied the newest kind-40003 by created_at regardless of who signed it, so a 40003 signed by anyone — targeting someone else's message — would rewrite that message in every reader's UI. The send side already gates Edit to your own messages, but the apply side re-checked nothing and effectively trusted the relay to reject cross-author edits. Enforce author-only on apply, matching feed (1010) and Concord (3302) edits: observeBuzzEdit now resolves through LocalCache.findLatestBuzzEditForNote, which keeps only edits whose author is the original message's author (newest by created_at — Buzz's own last-write-wins rule otherwise). There is no Buzz feature that edits another user's message; moderation is delete/hide. Added a test: a verified forged edit by a different author lands in the store but never overrides the message, while the real author's later edit does. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../amethyst/model/LocalCache.kt | 13 ++++++++ .../chats/feed/types/RenderBuzzNotes.kt | 13 +++----- .../model/BuzzWorkspaceChannelTest.kt | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 019571d0f5..5c7a3909a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3270,6 +3270,19 @@ object LocalCache : ILocalCache, ICacheProvider { fun cachedModificationEventsForNote(note: Note): List = findLatestModificationForNote(note) + /** + * The kind-40003 Buzz edit currently overlaying [note], or null when unedited. Like every other + * edit kind, only the ORIGINAL message author's edits count — the send side already gates Edit to + * your own messages, and the relay is not trusted to reject a cross-author edit, so a 40003 signed + * by anyone else can never rewrite your message. The newest by created_at wins (Buzz's rule). + */ + fun findLatestBuzzEditForNote(note: Note): Note? { + val authorHex = note.author?.pubkeyHex ?: return null + return note.edits + .filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex } + .maxByOrNull { it.createdAt() ?: 0L } + } + fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index bbe3986ef2..92441d91ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -60,7 +61,6 @@ import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent 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.nip01Core.core.Event @@ -69,19 +69,16 @@ import com.vitorpamplona.quartz.nip01Core.core.Event * arrive. Returns null when the message is unedited. * * Each edit is anchored on the message it edits ([Note.edits], where [LocalCache] consumes it), - * so it is held for as long as its message and read straight off the note — no channel-keyed - * side store. Buzz keeps the newest by `created_at` regardless of author (its own last-write-wins - * rule); [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold. + * so it is held for as long as its message and read straight off the note — no channel-keyed side + * store. [LocalCache.findLatestBuzzEditForNote] applies only the original author's newest edit; + * [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold. */ @Composable fun observeBuzzEdit(note: Note): Note? { val latest by produceState(initialValue = null, note.idHex) { note.flow().edits.stateFlow.collect { - value = - note.edits - .filter { it.event is StreamMessageEditEvent } - .maxByOrNull { it.createdAt() ?: 0L } + value = LocalCache.findLatestBuzzEditForNote(note) } } return latest diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 173b889dc4..7ba6894f6e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -39,6 +39,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -177,6 +178,36 @@ class BuzzWorkspaceChannelTest { assertEquals("edited offline", newest?.event?.content) } + @Test + fun aForgedEditByAnotherAuthorNeverOverridesTheMessage() = + runBlocking { + val channelId = newChannelId() + val original = streamMessage(channelId, "the truth") // authored by `signer` + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + + // Mallory publishes a well-formed, VERIFIED 40003 targeting someone else's message. + val mallory = NostrSignerInternal(KeyPair()) + val forged = + mallory.sign( + StreamMessageEditEvent.build(channelId, original.id, "lies", createdAt = original.createdAt + 100), + ) + LocalCache.checkDeletionAndConsume(forged, buzzRelay, false) + + // The forged edit still lands in the store (it is a valid signed event)… + val target = LocalCache.getNoteIfExists(original.id)!! + assertTrue("the forged edit is stored", target.edits.any { it.idHex == forged.id }) + // …but the overlay only applies the ORIGINAL author's edits, so it is ignored. + assertNull( + "an edit by a different author must never override the message", + LocalCache.findLatestBuzzEditForNote(target), + ) + + // The real author's own later edit does apply. + val real = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 200)) + LocalCache.checkDeletionAndConsume(real, buzzRelay, false) + assertEquals("the fix", LocalCache.findLatestBuzzEditForNote(target)?.event?.content) + } + @Test fun pruningAMessageReleasesItsEdits() = runBlocking { From 9fa1d769f9cc8b0c66af62ead0cde34ad3bfb802 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:38:11 +0000 Subject: [PATCH 07/11] refactor: drop cachedModificationEventsForNote, now a redundant alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It existed to serve observeEdits a cheap synchronous value (an LRU read) distinct from the expensive IO-only findLatestModificationForNote cache scan. Since edits fold from Note.edits, findLatestModificationForNote is itself cheap and thread-safe, and cachedModificationEventsForNote had become a plain alias for it. observeEdits now calls findLatestModificationForNote directly — the same function observeNoteModifications already uses — and the LocalCache + AccountViewModel aliases are removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../main/java/com/vitorpamplona/amethyst/model/LocalCache.kt | 2 -- .../main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt | 2 +- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 5c7a3909a5..c86b7d0c82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3268,8 +3268,6 @@ object LocalCache : ILocalCache, ICacheProvider { }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) } - fun cachedModificationEventsForNote(note: Note): List = findLatestModificationForNote(note) - /** * The kind-40003 Buzz edit currently overlaying [note], or null when unedited. Like every other * edit kind, only the ORIGINAL message author's edits count — the send side already gates Edit to diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b7d1b0a352..d1326aed5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -2062,7 +2062,7 @@ fun observeEdits( remember(baseNote.idHex) { // Edits are anchored on the note (Note.edits), so the current set is readable synchronously // (no cache scan) — start Empty or Loaded, never Loading. - val cached = accountViewModel.cachedModificationEventsForNote(baseNote) + val cached = LocalCache.findLatestModificationForNote(baseNote) mutableStateOf( if (cached.isEmpty()) { GenericLoadable.Empty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8bc5440009..73c94e020b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2037,8 +2037,6 @@ class AccountViewModel( fun getAddressableNoteIfExists(key: Address): AddressableNote? = LocalCache.getAddressableNoteIfExists(key) - fun cachedModificationEventsForNote(note: Note): List = LocalCache.cachedModificationEventsForNote(note) - fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key) fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key) From f9ad60c42365015b31fd2c1b26d5ebb8aa267a4c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:59:54 +0000 Subject: [PATCH 08/11] refactor: move edit-overlay resolvers off LocalCache onto Note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findLatestModificationForNote (and the Buzz resolver) were pure Note.edits filters with no LocalCache state — they only lived there for historical reasons (back when resolving edits meant scanning the whole cache). Now that every edit is a hard-referenced child of its message, resolution is a cheap in-memory fold that belongs on the note. New NoteEditOverlays.kt collects all three as Note extensions, so every edit kind resolves the same way and none touches LocalCache: - Note.textNoteModifications() (1010, author-only + NIP-40, version list) - Note.latestBuzzEdit() (40003, author-only, newest by created_at) - Note.latestConcordEdit() (3302, author-only, newest by CORD-02 send time) Callers updated: observeEdits, observeNoteModifications, observeBuzzEdit, observeConcordEdit, and the Buzz test. observeConcordEdit also drops its early-return-before-produceState guards (a conditional-hook hazard) since the resolver returns null for a non-Concord note anyway. LocalCache no longer carries any edit-filtering logic. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../amethyst/model/LocalCache.kt | 32 +-------- .../amethyst/model/NoteEditOverlays.kt | 69 +++++++++++++++++++ .../reqCommand/event/EventObservers.kt | 4 +- .../amethyst/ui/note/NoteCompose.kt | 3 +- .../chats/feed/types/RenderBuzzNotes.kt | 10 +-- .../chats/feed/types/RenderConcordEdits.kt | 18 ++--- .../model/BuzzWorkspaceChannelTest.kt | 4 +- 7 files changed, 85 insertions(+), 55 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c86b7d0c82..c1c5bf1952 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2775,7 +2775,7 @@ object LocalCache : ILocalCache, ICacheProvider { note.loadEvent(event, author, emptyList()) // Anchor the modification to the note it edits, like every other edit kind — the read side - // ([findLatestModificationForNote]) then folds `edited.edits` instead of scanning the cache, + // (Note.textNoteModifications) then folds `edited.edits` instead of scanning the cache, // and addEdit invalidates the note's edits flow so the UI re-derives. event.editedNote()?.let { checkGetOrCreateNote(it.eventId)?.addEdit(note) @@ -3251,36 +3251,6 @@ object LocalCache : ILocalCache, ICacheProvider { return minTime } - /** - * The NIP-1010 edits of [note] to apply, oldest first — folded from the note's own - * [Note.edits] (where [consume] anchors each modification) rather than scanned from the - * whole cache. Only the original author's edits count, and expired (NIP-40) ones are - * dropped. Cheap (bounded by this note's edits), so it's safe to call from any thread. - */ - fun findLatestModificationForNote(note: Note): List { - val noteAuthor = note.author ?: return emptyList() - val time = TimeUtils.now() - - return note.edits - .filter { item -> - val noteEvent = item.event - noteEvent is TextNoteModificationEvent && noteAuthor == item.author && !noteEvent.isExpirationBefore(time) - }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - } - - /** - * The kind-40003 Buzz edit currently overlaying [note], or null when unedited. Like every other - * edit kind, only the ORIGINAL message author's edits count — the send side already gates Edit to - * your own messages, and the relay is not trusted to reject a cross-author edit, so a 40003 signed - * by anyone else can never rewrite your message. The newest by created_at wins (Buzz's rule). - */ - fun findLatestBuzzEditForNote(note: Note): Note? { - val authorHex = note.author?.pubkeyHex ?: return null - return note.edits - .filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex } - .maxByOrNull { it.createdAt() ?: 0L } - } - fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt new file mode 100644 index 0000000000..aebd5bdb24 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt @@ -0,0 +1,69 @@ +/* + * 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.model + +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.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.utils.TimeUtils + +/* + * Per-kind resolution of a message's edit overlay from its own `Note.edits`. Every edit that + * targets a note is held there as a hard-referenced child (like a reaction), so these are cheap + * 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. + */ + +/** + * Every kind-1010 post modification of this note, oldest first (author-only, dropping NIP-40 + * expired ones). A list because the post's EditState cycles through the original + each version. + */ +fun Note.textNoteModifications(): List { + val noteAuthor = author ?: return emptyList() + val now = TimeUtils.now() + return edits + .filter { item -> + val e = item.event + e is TextNoteModificationEvent && noteAuthor == item.author && !e.isExpirationBefore(now) + }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) +} + +/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */ +fun Note.latestBuzzEdit(): Note? { + val authorHex = author?.pubkeyHex ?: return null + return edits + .filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex } + .maxByOrNull { it.createdAt() ?: 0L } +} + +/** 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 + return edits + .filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent } + .maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex })) + ?.takeIf { it.event != null } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index 8715fdc6eb..b52f7ee594 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -25,10 +25,10 @@ import androidx.compose.runtime.State import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.textNoteModifications import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply import com.vitorpamplona.quartz.nip01Core.core.Event @@ -418,7 +418,7 @@ fun observeNoteModifications( .edits .stateFlow .sample(500) - .mapLatest { LocalCache.findLatestModificationForNote(note) } + .mapLatest { note.textNoteModifications() } .distinctUntilChanged() .flowOn(Dispatchers.IO) .collect { value = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index d1326aed5a..0c454edef5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.textNoteModifications import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent @@ -2062,7 +2063,7 @@ fun observeEdits( remember(baseNote.idHex) { // Edits are anchored on the note (Note.edits), so the current set is readable synchronously // (no cache scan) — start Empty or Loaded, never Loading. - val cached = LocalCache.findLatestModificationForNote(baseNote) + val cached = baseNote.textNoteModifications() mutableStateOf( if (cached.isEmpty()) { GenericLoadable.Empty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index 92441d91ab..9e9c7d3371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -42,8 +42,8 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.latestBuzzEdit import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -68,17 +68,17 @@ import com.vitorpamplona.quartz.nip01Core.core.Event * Observes the newest kind-40003 edit overlaying [note], recomposing when new edits * arrive. Returns null when the message is unedited. * - * Each edit is anchored on the message it edits ([Note.edits], where [LocalCache] consumes it), + * Each edit is anchored on the message it edits ([Note.edits], where LocalCache consumes it), * so it is held for as long as its message and read straight off the note — no channel-keyed side - * store. [LocalCache.findLatestBuzzEditForNote] applies only the original author's newest edit; - * [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold. + * store. [Note.latestBuzzEdit] applies only the original author's newest edit; [addEdit] + * invalidates the note's edits flow, so collecting it re-runs the fold. */ @Composable fun observeBuzzEdit(note: Note): Note? { val latest by produceState(initialValue = null, note.idHex) { note.flow().edits.stateFlow.collect { - value = LocalCache.findLatestBuzzEditForNote(note) + value = note.latestBuzzEdit() } } 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/RenderConcordEdits.kt index 8436f7554d..7337f084dd 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/RenderConcordEdits.kt @@ -33,15 +33,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.latestConcordEdit import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent /** * Observes the newest kind-3302 Concord edit overlaying a Concord chat message [note], @@ -59,21 +58,12 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent */ @Composable fun observeConcordEdit(note: Note): Note? { - // Key on note.event, not note: LocalCache mutates a Note in place, so keying on the Note instance - // would cache a null gatherer taken before the event populated and never recompute for that row. - val isConcord = remember(note.event) { note.inGatherers?.any { it is ConcordChannel } == true } - if (!isConcord) return null - val authorHex = note.author?.pubkeyHex ?: return null - - // `addEdit` invalidates this flow, so collecting it re-runs the fold below on each new edit. + // `addEdit` invalidates this flow, so collecting it re-runs the fold on each new edit. A non-Concord + // message simply has no kind-3302 edits, so [Note.latestConcordEdit] returns null for it. val latest by produceState(initialValue = null, note.idHex) { note.flow().edits.stateFlow.collect { - value = - note.edits - .filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent } - .maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex })) - ?.takeIf { it.event != null } + value = note.latestConcordEdit() } } return latest diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 7ba6894f6e..03371a205a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -199,13 +199,13 @@ class BuzzWorkspaceChannelTest { // …but the overlay only applies the ORIGINAL author's edits, so it is ignored. assertNull( "an edit by a different author must never override the message", - LocalCache.findLatestBuzzEditForNote(target), + target.latestBuzzEdit(), ) // The real author's own later edit does apply. val real = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 200)) LocalCache.checkDeletionAndConsume(real, buzzRelay, false) - assertEquals("the fix", LocalCache.findLatestBuzzEditForNote(target)?.event?.content) + assertEquals("the fix", target.latestBuzzEdit()?.event?.content) } @Test From 893a270c65e91c0e2f0379e2f384e85a23f1010c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 20:18:53 +0000 Subject: [PATCH 09/11] fix(edits): unlink deleted edits from their message; one collector per row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the three edit paths (feed 1010 / Buzz 40003 / Concord 3302) found: 1. Bug (feed regression): a deleted edit kept overlaying its message. Edits anchor on the target's Note.edits with no `replyTo` back-link, and removeNote didn't cover `edits`, so unlinkAndRemove never dropped them — the old cache-scan resolver dropped deleted edits for free, Note.edits did not. Fix: removeNote now also removeEdit()s, and unlinkAndRemove resolves the edit's `e`-tag target and unlinks it there (editedTargetIdOf covers all three kinds). New test: deleting an edit un-overlays and unlinks it. 2. Perf: every chat row ran two edits-flow collectors (observeConcordEdit + observeBuzzEdit). A message is only ever one kind, so they're merged into a single observeChatEdit that resolves latestConcordEdit() ?: latestBuzzEdit() — one collector per row, dispatched by the winning edit's event type. 3. Nits: latestBuzzEdit now tie-breaks by idHex (deterministic on same-second edits, matching Concord); dropped a redundant takeIf in latestConcordEdit. The author check stays at read time on purpose: an edit can be consumed before its target loads (author unknown), so an attach-time gate would wrongly drop early-arriving legit edits. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6 --- .../amethyst/model/LocalCache.kt | 14 +++++++ .../amethyst/model/NoteEditOverlays.kt | 4 +- .../loggedIn/chats/feed/ChatMessageCompose.kt | 38 ++++++++++++++----- .../chats/feed/types/RenderBuzzNotes.kt | 23 ----------- .../chats/feed/types/RenderConcordEdits.kt | 31 --------------- .../model/BuzzWorkspaceChannelTest.kt | 22 +++++++++++ .../amethyst/commons/model/Note.kt | 1 + 7 files changed, 67 insertions(+), 66 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c1c5bf1952..99fa220f12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3567,6 +3567,11 @@ object LocalCache : ILocalCache, ICacheProvider { getNoteIfExists(quotedId)?.removeBoost(note) } + // Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo` + // back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag + // and drop it there, or a deleted edit would keep overlaying its message. + editedTargetIdOf(noteEvent)?.let { getNoteIfExists(it)?.removeEdit(note) } + if (noteEvent is ReportEvent) { noteEvent.reportedAuthor().forEach { getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> @@ -3605,6 +3610,15 @@ object LocalCache : ILocalCache, ICacheProvider { refreshDeletedNoteObservers(note) } + /** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */ + private fun editedTargetIdOf(event: Event?): HexKey? = + when (event) { + is TextNoteModificationEvent -> event.editedNote()?.eventId + is ConcordChatEditEvent -> event.editedMessageId() + is StreamMessageEditEvent -> event.editedMessage() + else -> null + } + fun unlinkAndRemove(nextToBeRemoved: List) { nextToBeRemoved.forEach { note -> unlinkAndRemove(note) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt index aebd5bdb24..a460743c5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt @@ -56,7 +56,8 @@ fun Note.latestBuzzEdit(): Note? { val authorHex = author?.pubkeyHex ?: return null return edits .filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex } - .maxByOrNull { it.createdAt() ?: 0L } + // idHex tie-break so a same-second pair resolves identically on every client. + .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. */ @@ -65,5 +66,4 @@ fun Note.latestConcordEdit(): Note? { return edits .filter { it.author?.pubkeyHex == authorHex && it.event is ConcordChatEditEvent } .maxWithOrNull(compareBy({ (it.event as ConcordChatEditEvent).orderingMs() }, { it.idHex })) - ?.takeIf { it.event != null } } 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 dc3a8108fa..fe5f68d670 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 @@ -46,6 +46,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.latestBuzzEdit +import com.vitorpamplona.amethyst.model.latestConcordEdit import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -72,13 +74,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarm import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeConcordEdit import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent 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.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent @@ -615,17 +617,33 @@ 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-1010 and - // kind-40003 respectively): when one exists, render the newest edit's content - // instead of the stale original. Both are null for every other chat surface. - val concordEdit = observeConcordEdit(note) - val buzzEdit = observeBuzzEdit(note) - when { - concordEdit != null -> RenderConcordEditedNote(note, concordEdit, canPreview, innerQuote, bgColor, accountViewModel, nav) - buzzEdit != null -> RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav) + // 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). + 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) else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav) } } } } } + +/** + * The newest edit overlaying a chat message [note] (Concord kind-3302 or Buzz kind-40003), or null + * when unedited. A message is only ever one kind, so both resolve off the same [Note.edits] and one + * collector on the note's edits flow serves both — recomposing whenever an edit is added or removed. + */ +@Composable +fun observeChatEdit(note: Note): Note? { + val latest by + produceState(initialValue = null, note.idHex) { + note.flow().edits.stateFlow.collect { + value = note.latestConcordEdit() ?: note.latestBuzzEdit() + } + } + return latest +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index 9e9c7d3371..2afe30722f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -43,7 +41,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.latestBuzzEdit import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,26 +61,6 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.nip01Core.core.Event -/** - * Observes the newest kind-40003 edit overlaying [note], recomposing when new edits - * arrive. Returns null when the message is unedited. - * - * Each edit is anchored on the message it edits ([Note.edits], where LocalCache consumes it), - * so it is held for as long as its message and read straight off the note — no channel-keyed side - * store. [Note.latestBuzzEdit] applies only the original author's newest edit; [addEdit] - * invalidates the note's edits flow, so collecting it re-runs the fold. - */ -@Composable -fun observeBuzzEdit(note: Note): Note? { - val latest by - produceState(initialValue = null, note.idHex) { - note.flow().edits.stateFlow.collect { - value = note.latestBuzzEdit() - } - } - return latest -} - /** * A Buzz stream message whose content has been superseded by a kind-40003 edit: * renders the NEWEST edit's content (never the stale original) plus an "(edited)" 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/RenderConcordEdits.kt index 7337f084dd..5004e87e6b 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/RenderConcordEdits.kt @@ -25,8 +25,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -34,41 +32,12 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.latestConcordEdit import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -/** - * Observes the newest kind-3302 Concord edit overlaying a Concord chat message [note], - * recomposing when a new edit lands. Returns null for non-Concord messages or an - * unedited one. - * - * Concord edits ride the encrypted channel plane (unlike public feed edits, there is no - * relay subscription to start — the session decrypts the wrap and lands the kind-3302 - * rumor in [LocalCache] itself). Each edit is attached to the message it edits ([Note.edits], - * like a reaction to its target), so it is held for exactly as long as the channel-retained - * message — a Concord rumor is decrypted once per session and can't be re-downloaded, so it - * must not be left orphaned in the soft cache. We recompute from that list whenever it changes. - * Only edits authored by the original message's author are applied — so a member can't rewrite - * someone else's message — and the latest by CORD-02 §4 send time wins. - */ -@Composable -fun observeConcordEdit(note: Note): Note? { - // `addEdit` invalidates this flow, so collecting it re-runs the fold on each new edit. A non-Concord - // message simply has no kind-3302 edits, so [Note.latestConcordEdit] returns null for it. - val latest by - produceState(initialValue = null, note.idHex) { - note.flow().edits.stateFlow.collect { - value = note.latestConcordEdit() - } - } - return latest -} - /** * 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)" diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 03371a205a..6632a51f0d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import io.mockk.every import io.mockk.mockk @@ -208,6 +209,27 @@ class BuzzWorkspaceChannelTest { assertEquals("the fix", target.latestBuzzEdit()?.event?.content) } + @Test + fun deletingAnEditUnlinksItFromTheMessage() = + runBlocking { + val channelId = newChannelId() + val original = streamMessage(channelId, "typo") + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "fixed", createdAt = original.createdAt + 5)) + LocalCache.checkDeletionAndConsume(edit, buzzRelay, false) + + val target = LocalCache.getNoteIfExists(original.id)!! + assertEquals("fixed", target.latestBuzzEdit()?.event?.content) + + // The author deletes their own edit (NIP-09). It must stop overlaying the message and + // be unlinked from Note.edits, not linger as a stale overlay. + val deletion = signer.sign(DeletionEvent.build(listOf(edit))) + LocalCache.checkDeletionAndConsume(deletion, buzzRelay, false) + + assertNull("a deleted edit must no longer overlay its message", target.latestBuzzEdit()) + assertTrue("the deleted edit is unlinked from the message", target.edits.none { it.idHex == edit.id }) + } + @Test fun pruningAMessageReleasesItsEdits() = runBlocking { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 9eb40f6176..4a97a79451 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -163,6 +163,7 @@ open class Note( removeReply(note) removeBoost(note) removeReaction(note) + removeEdit(note) removeZap(note) removeZapPayment(note) removeReport(note) From 3fb44dba60bf2c98eadc0df1c7d2ded7f2e91244 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 20:38:43 +0000 Subject: [PATCH 10/11] feat: gallery view for My Blossom Files with per-file detail sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-column list of large blob cards with an adaptive thumbnail grid so many files no longer mean an endless scroll. Each tile shows an image preview (or a type glyph) plus a corner badge summarizing how many of the user's servers hold it (green check when on all, amber cloud with a present/total count otherwise). Tapping a tile opens a bottom sheet with the file's details: hash, type/size, the per-server storage matrix ("Stored on"), the sync (mirror-to-missing) button, and the copy/open/report/delete actions that previously lived behind the card's overflow menu. The ViewModel is unchanged — only the presentation layer moved from list-of-cards to grid-plus-detail-sheet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7 --- .../mediaServers/BlossomBlobManagerScreen.kt | 339 ++++++++++++------ amethyst/src/main/res/values/strings.xml | 3 + 2 files changed, 234 insertions(+), 108 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 69afaddab3..9629fcc5f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers import android.content.Intent import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,28 +31,36 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -86,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip56Reports.ReportType import kotlinx.coroutines.launch @@ -104,6 +114,11 @@ fun BlossomBlobManagerScreen( val error by vm.error.collectAsStateWithLifecycle() val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle() + // The tapped file, if any. We keep only the hash and re-resolve the row from the + // live list each recomposition so the open sheet stays in sync with mirror/delete + // updates (and closes itself when the last copy of the blob is deleted). + var selectedHash by remember { mutableStateOf(null) } + pendingPayment?.let { pending -> BlossomPaymentDialog( host = pending.targetHost, @@ -114,6 +129,19 @@ fun BlossomBlobManagerScreen( ) } + selectedHash?.let { hash -> + val selected = blobs.firstOrNull { it.hash == hash } + if (selected == null) { + selectedHash = null + } else { + BlobDetailSheet( + row = selected, + vm = vm, + onDismiss = { selectedHash = null }, + ) + } + } + Scaffold( topBar = { TopBarExtensibleWithBackButton( @@ -164,16 +192,20 @@ fun BlossomBlobManagerScreen( } else -> - LazyColumn( + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 104.dp), modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { if (blobs.any { it.hasMissing }) { - item { SyncAllBanner(onSyncAll = { vm.syncAll() }) } + item(span = { GridItemSpan(maxLineSpan) }) { + SyncAllBanner(onSyncAll = { vm.syncAll() }) + } } items(blobs, key = { it.hash }) { row -> - BlobCard(row, vm) + GalleryTile(row, onClick = { selectedHash = row.hash }) } } } @@ -229,108 +261,179 @@ private fun SyncAllBanner(onSyncAll: () -> Unit) { } } -@OptIn(ExperimentalLayoutApi::class) +/** + * One gallery cell: a square thumbnail (image preview or a type glyph) with a small + * corner badge summarizing how many of the user's servers hold this blob. Tapping it + * opens [BlobDetailSheet] with the storage matrix and the sync/delete/report actions. + */ @Composable -private fun BlobCard( +private fun GalleryTile( + row: BlobRow, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable(onClick = onClick), + ) { + if (row.url != null && row.type?.startsWith("image/") == true) { + AsyncImage( + model = row.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + symbol = glyphFor(row.type), + contentDescription = null, + modifier = Modifier.size(34.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + SyncBadge( + row = row, + modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), + ) + } +} + +/** + * Corner chip over a gallery tile: a green check when the blob is on every server, an + * amber cloud when some server is still missing it, plus a `present/total` count so the + * spread is legible at a glance without opening the file. + */ +@Composable +private fun SyncBadge( + row: BlobRow, + modifier: Modifier = Modifier, +) { + val synced = !row.hasMissing + val accent = if (synced) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.tertiary + Row( + modifier = + modifier + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .padding(horizontal = 7.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = if (synced) MaterialSymbols.CheckCircle else MaterialSymbols.CloudUpload, + contentDescription = + stringRes( + if (synced) R.string.blossom_on_all_servers else R.string.blossom_not_on_all_servers, + ), + modifier = Modifier.size(13.dp), + tint = accent, + ) + Text( + text = "${row.presentCount}/${row.servers.size}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun BlobDetailSheet( row: BlobRow, vm: BlossomBlobManagerViewModel, + onDismiss: () -> Unit, ) { - var menuOpen by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var reportOpen by remember { mutableStateOf(false) } val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() val context = LocalContext.current - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - // Header: thumbnail / file glyph + hash + overflow menu. - Row(verticalAlignment = Alignment.CenterVertically) { - BlobThumbnail(row) - Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Preview + identity. + Row(verticalAlignment = Alignment.CenterVertically) { + DetailThumbnail(row) + Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) { + Text( + text = row.hash.take(12) + "…" + row.hash.takeLast(6), + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + + // Where the file lives. + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - text = row.hash.take(12) + "…" + row.hash.takeLast(6), - style = MaterialTheme.typography.titleSmall, - fontFamily = FontFamily.Monospace, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - Text( - text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, + text = stringRes(R.string.blossom_stored_on), + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.grayText, ) - } - - Box { - IconButton(onClick = { menuOpen = true }) { - Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - if (row.url != null) { - DropdownMenuItem( - text = { Text(stringRes(R.string.copy)) }, - leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) }, - onClick = { - menuOpen = false - val url = row.url - scope.launch { clipboard.setText(url) } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.blossom_open)) }, - leadingIcon = { MenuIcon(MaterialSymbols.AutoMirrored.OpenInNew) }, - onClick = { - menuOpen = false - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) } - }, - ) - } - if (row.hasPresent) { - DropdownMenuItem( - text = { Text(stringRes(R.string.blossom_report)) }, - leadingIcon = { MenuIcon(MaterialSymbols.Report) }, - onClick = { - menuOpen = false - reportOpen = true - }, - ) - HorizontalDivider() - row.presentServers.forEach { server -> - DropdownMenuItem( - text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) }, - leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) }, - onClick = { - menuOpen = false - vm.delete(row.hash, server) - }, - ) - } - } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.servers.forEach { ServerPill(it) } } } - } - // Per-server presence pills (green = has it, grey = missing, spinner = working). - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - row.servers.forEach { ServerPill(it) } - } + // Primary CTA: fill the gaps for this file. + if (row.hasMissing && row.url != null) { + FilledTonalButton( + onClick = { vm.mirrorToMissing(row) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringRes(R.string.blossom_mirror_to_missing)) + } + } - // Primary CTA: fill the gaps. - if (row.hasMissing && row.url != null) { - FilledTonalButton( - onClick = { vm.mirrorToMissing(row) }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(8.dp)) - Text(stringRes(R.string.blossom_mirror_to_missing)) + // Secondary actions. + if (row.url != null) { + DetailAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy)) { + val url = row.url + scope.launch { clipboard.setText(url) } + } + DetailAction(MaterialSymbols.AutoMirrored.OpenInNew, stringRes(R.string.blossom_open)) { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) } + } + } + + if (row.hasPresent) { + DetailAction(MaterialSymbols.Report, stringRes(R.string.blossom_report)) { reportOpen = true } + + HorizontalDivider() + + row.presentServers.forEach { server -> + DetailAction( + symbol = MaterialSymbols.Delete, + label = stringRes(R.string.blossom_delete_from_host, vm.hostOf(server)), + tint = MaterialTheme.colorScheme.error, + ) { vm.delete(row.hash, server) } + } } } } @@ -341,31 +444,59 @@ private fun BlobCard( } @Composable -private fun BlobThumbnail(row: BlobRow) { - val shape = RoundedCornerShape(12.dp) +private fun DetailAction( + symbol: MaterialSymbol, + label: String, + tint: Color = MaterialTheme.colorScheme.onSurface, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(22.dp), tint = tint) + Text(text = label, style = MaterialTheme.typography.bodyLarge, color = tint) + } +} + +@Composable +private fun DetailThumbnail(row: BlobRow) { + val shape = RoundedCornerShape(14.dp) if (row.url != null && row.type?.startsWith("image/") == true) { AsyncImage( model = row.url, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.size(48.dp).clip(shape), + modifier = Modifier.size(64.dp).clip(shape), ) } else { Box( - modifier = Modifier.size(48.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer), + modifier = Modifier.size(64.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center, ) { - val glyph = if (row.type?.startsWith("video/") == true) MaterialSymbols.Download else MaterialSymbols.Storage Icon( - symbol = glyph, + symbol = glyphFor(row.type), contentDescription = null, - modifier = Modifier.size(22.dp), + modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) } } } +private fun glyphFor(type: String?): MaterialSymbol = + when { + type?.startsWith("image/") == true -> MaterialSymbols.Image + type?.startsWith("video/") == true -> MaterialSymbols.PlayCircle + else -> MaterialSymbols.Storage + } + @Composable private fun ServerPill(presence: ServerPresence) { val present = presence.state == PresenceState.PRESENT @@ -396,14 +527,6 @@ private fun ServerPill(presence: ServerPresence) { } } -@Composable -private fun MenuIcon( - symbol: MaterialSymbol, - tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, -) { - Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) -} - private fun humanBytes(bytes: Long): String = when { bytes >= 1_000_000 -> "${bytes / 1_000_000} MB" diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2052e7b286..282051e8b6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1671,6 +1671,9 @@ Blossom sync Shows progress while copying your files across your Blossom servers. No stored files found on your Blossom servers. + Stored on + On all servers + Not on all servers Mirror to missing Delete from… Delete from %1$s From ccd991c84778e455289bd2522972b7fa2884624a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:26:35 +0000 Subject: [PATCH 11/11] feat: video thumbnails + full-screen zoomable viewer for Blossom files Videos in the gallery now show a decoded first frame (Coil VideoFrameDecoder) with a play badge instead of a generic glyph; the same preview is reused in the detail header, with a type-glyph fallback for blobs Coil can't decode (e.g. HLS playlists). Tapping an image or video tile now opens a full-screen viewer: images are zoomable/pannable (engawapg zoomable, matching ZoomableImageDialog) and videos play inline. Its top bar carries back, a new share action (system share sheet for the blob URL), and a button that reveals the file's storage matrix and sync/copy/open/share/report/delete actions in a bottom drawer. Non-visual blobs (PDFs, arbitrary files) still open straight to that action sheet. The actions list is now a shared BlobActionsContent so the sheet and the viewer drawer stay identical. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7 --- .../mediaServers/BlossomBlobManagerScreen.kt | 456 +++++++++++++----- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 334 insertions(+), 123 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 9629fcc5f0..22eefb038e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import android.content.Context import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -32,11 +33,14 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -58,11 +62,13 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -79,15 +85,22 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton @@ -98,6 +111,8 @@ import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip56Reports.ReportType import kotlinx.coroutines.launch +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable @Composable fun BlossomBlobManagerScreen( @@ -115,8 +130,8 @@ fun BlossomBlobManagerScreen( val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle() // The tapped file, if any. We keep only the hash and re-resolve the row from the - // live list each recomposition so the open sheet stays in sync with mirror/delete - // updates (and closes itself when the last copy of the blob is deleted). + // live list each recomposition so the open viewer/sheet stays in sync with + // mirror/delete updates (and closes itself when the last copy of the blob is deleted). var selectedHash by remember { mutableStateOf(null) } pendingPayment?.let { pending -> @@ -131,14 +146,26 @@ fun BlossomBlobManagerScreen( selectedHash?.let { hash -> val selected = blobs.firstOrNull { it.hash == hash } - if (selected == null) { - selectedHash = null - } else { - BlobDetailSheet( - row = selected, - vm = vm, - onDismiss = { selectedHash = null }, - ) + when { + selected == null -> selectedHash = null + + // Images and videos open in the full-screen zoomable viewer, which carries + // the actions in its own bottom drawer. Everything else (PDFs, arbitrary + // blobs) has nothing to zoom, so it goes straight to the actions sheet. + selected.url != null && selected.isViewable -> + BlossomBlobViewer( + row = selected, + vm = vm, + accountViewModel = accountViewModel, + onDismiss = { selectedHash = null }, + ) + + else -> + BlobDetailSheet( + row = selected, + vm = vm, + onDismiss = { selectedHash = null }, + ) } } @@ -213,6 +240,10 @@ fun BlossomBlobManagerScreen( } } +/** Whether a blob is an image or a video, i.e. it can be previewed and shown full-screen. */ +private val BlobRow.isViewable: Boolean + get() = type?.let { it.startsWith("image/") || it.startsWith("video/") } == true + @Composable private fun CenteredState(content: @Composable () -> Unit) { Column( @@ -262,9 +293,9 @@ private fun SyncAllBanner(onSyncAll: () -> Unit) { } /** - * One gallery cell: a square thumbnail (image preview or a type glyph) with a small - * corner badge summarizing how many of the user's servers hold this blob. Tapping it - * opens [BlobDetailSheet] with the storage matrix and the sync/delete/report actions. + * One gallery cell: a square preview — the image itself, or a decoded first frame for a + * video (with a play badge) — plus a corner badge summarizing how many of the user's + * servers hold this blob. Tapping it opens the full-screen viewer / actions. */ @Composable private fun GalleryTile( @@ -279,23 +310,7 @@ private fun GalleryTile( .background(MaterialTheme.colorScheme.surfaceContainer) .clickable(onClick = onClick), ) { - if (row.url != null && row.type?.startsWith("image/") == true) { - AsyncImage( - model = row.url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - symbol = glyphFor(row.type), - contentDescription = null, - modifier = Modifier.size(34.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + BlobPreview(row = row, modifier = Modifier.fillMaxSize()) SyncBadge( row = row, @@ -304,6 +319,68 @@ private fun GalleryTile( } } +/** + * Renders a blob's visual preview inside [modifier]'s bounds: the image, or a video's + * first frame (decoded by Coil's VideoFrameDecoder) with a centered play glyph. Falls + * back to a type glyph while loading fails or for non-visual blobs (e.g. an HLS playlist + * Coil can't decode). + */ +@Composable +private fun BlobPreview( + row: BlobRow, + modifier: Modifier = Modifier, + glyphSize: Dp = 34.dp, + playIconSize: Dp = 40.dp, +) { + val isVideo = row.type?.startsWith("video/") == true + Box(modifier = modifier, contentAlignment = Alignment.Center) { + if (row.url != null && row.isViewable) { + SubcomposeAsyncImage( + model = row.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) { + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent() + if (isVideo) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + symbol = MaterialSymbols.PlayCircle, + contentDescription = null, + modifier = Modifier.size(playIconSize), + tint = Color.White, + ) + } + } + } + + is AsyncImagePainter.State.Error -> BlobGlyph(row, glyphSize) + + else -> {} + } + } + } else { + BlobGlyph(row, glyphSize) + } + } +} + +@Composable +private fun BlobGlyph( + row: BlobRow, + size: Dp, +) { + Icon( + symbol = glyphFor(row.type), + contentDescription = null, + modifier = Modifier.size(size), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + /** * Corner chip over a gallery tile: a green check when the blob is on every server, an * amber cloud when some server is still missing it, plus a `present/total` count so the @@ -342,7 +419,125 @@ private fun SyncBadge( } } -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +/** + * Full-screen viewer opened from a gallery tile: the image is zoomable/pannable and a + * video plays inline, matching the app's [com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog]. + * The blob's storage matrix and its sync/copy/open/share/report/delete actions live in a + * bottom drawer reached from the top bar, so they don't cover the media until asked for. + */ +@Composable +private fun BlossomBlobViewer( + row: BlobRow, + vm: BlossomBlobManagerViewModel, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var drawerOpen by remember { mutableStateOf(false) } + val isVideo = row.type?.startsWith("video/") == true + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + val url = row.url + if (url != null && isVideo) { + val controllerVisible = remember { mutableStateOf(true) } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + VideoViewInner( + videoUri = url, + mimeType = row.type, + contentScale = ContentScale.Fit, + borderModifier = Modifier.fillMaxWidth(), + automaticallyStartPlayback = true, + controllerVisible = controllerVisible, + isFullscreen = true, + accountViewModel = accountViewModel, + ) + } + } else if (url != null) { + val zoomState = rememberZoomState() + AsyncImage( + model = url, + contentDescription = row.hash, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().zoomable(zoomState), + ) + } + + // Top bar: back, share, and the drawer toggle. + Row( + modifier = + Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ViewerIconButton(MaterialSymbols.AutoMirrored.ArrowBack, stringRes(R.string.back), onDismiss) + Spacer(Modifier.weight(1f)) + if (url != null) { + ViewerIconButton(MaterialSymbols.Share, stringRes(R.string.quick_action_share)) { + shareUrl(context, url) + } + } + ViewerIconButton(MaterialSymbols.Info, stringRes(R.string.blossom_file_details)) { + drawerOpen = true + } + } + + // Bottom drawer with the file's storage matrix and actions. + if (drawerOpen) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable(onClick = { drawerOpen = false }), + ) + Surface( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = MaterialTheme.colorScheme.surface, + ) { + BlobActionsContent( + row = row, + vm = vm, + modifier = + Modifier + .fillMaxHeight(0.7f) + .navigationBarsPadding() + // Swallow taps so the scrim behind doesn't dismiss the drawer. + .clickable(enabled = false) {}, + ) + } + } + } + } +} + +@Composable +private fun ViewerIconButton( + symbol: MaterialSymbol, + contentDescription: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier.clip(CircleShape).background(Color.Black.copy(alpha = 0.4f)), + ) { + Icon(symbol = symbol, contentDescription = contentDescription, tint = Color.White) + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun BlobDetailSheet( row: BlobRow, @@ -350,90 +545,118 @@ private fun BlobDetailSheet( onDismiss: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + BlobActionsContent(row = row, vm = vm, modifier = Modifier.navigationBarsPadding()) + } +} + +/** + * The blob's detail + action list, shared by the [BlobDetailSheet] (non-visual blobs) + * and by [BlossomBlobViewer]'s bottom drawer: a preview + hash/size header, the "Stored + * on" per-server matrix, the sync (mirror-to-missing) button, and the + * copy/open/share/report/delete actions. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun BlobActionsContent( + row: BlobRow, + vm: BlossomBlobManagerViewModel, + modifier: Modifier = Modifier, +) { var reportOpen by remember { mutableStateOf(false) } val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() val context = LocalContext.current - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 28.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Preview + identity. - Row(verticalAlignment = Alignment.CenterVertically) { - DetailThumbnail(row) - Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) { - Text( - text = row.hash.take(12) + "…" + row.hash.takeLast(6), - style = MaterialTheme.typography.titleSmall, - fontFamily = FontFamily.Monospace, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - Text( - text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.grayText, - ) - } + Column( + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Preview + identity. + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + BlobPreview(row = row, modifier = Modifier.fillMaxSize(), glyphSize = 28.dp, playIconSize = 28.dp) } - - // Where the file lives. - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) { Text( - text = stringRes(R.string.blossom_stored_on), - style = MaterialTheme.typography.labelLarge, + text = row.hash.take(12) + "…" + row.hash.takeLast(6), + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.grayText, ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - row.servers.forEach { ServerPill(it) } - } } + } - // Primary CTA: fill the gaps for this file. - if (row.hasMissing && row.url != null) { - FilledTonalButton( - onClick = { vm.mirrorToMissing(row) }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(8.dp)) - Text(stringRes(R.string.blossom_mirror_to_missing)) - } + // Where the file lives. + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringRes(R.string.blossom_stored_on), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.servers.forEach { ServerPill(it) } } + } - // Secondary actions. - if (row.url != null) { - DetailAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy)) { - val url = row.url - scope.launch { clipboard.setText(url) } - } - DetailAction(MaterialSymbols.AutoMirrored.OpenInNew, stringRes(R.string.blossom_open)) { - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) } - } + // Primary CTA: fill the gaps for this file. + if (row.hasMissing && row.url != null) { + FilledTonalButton( + onClick = { vm.mirrorToMissing(row) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringRes(R.string.blossom_mirror_to_missing)) } + } - if (row.hasPresent) { - DetailAction(MaterialSymbols.Report, stringRes(R.string.blossom_report)) { reportOpen = true } + // Secondary actions. + if (row.url != null) { + val url = row.url + DetailAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy)) { + scope.launch { clipboard.setText(url) } + } + DetailAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share)) { + shareUrl(context, url) + } + DetailAction(MaterialSymbols.AutoMirrored.OpenInNew, stringRes(R.string.blossom_open)) { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } + } + } - HorizontalDivider() + if (row.hasPresent) { + DetailAction(MaterialSymbols.Report, stringRes(R.string.blossom_report)) { reportOpen = true } - row.presentServers.forEach { server -> - DetailAction( - symbol = MaterialSymbols.Delete, - label = stringRes(R.string.blossom_delete_from_host, vm.hostOf(server)), - tint = MaterialTheme.colorScheme.error, - ) { vm.delete(row.hash, server) } - } + HorizontalDivider() + + row.presentServers.forEach { server -> + DetailAction( + symbol = MaterialSymbols.Delete, + label = stringRes(R.string.blossom_delete_from_host, vm.hostOf(server)), + tint = MaterialTheme.colorScheme.error, + ) { vm.delete(row.hash, server) } } } } @@ -465,31 +688,6 @@ private fun DetailAction( } } -@Composable -private fun DetailThumbnail(row: BlobRow) { - val shape = RoundedCornerShape(14.dp) - if (row.url != null && row.type?.startsWith("image/") == true) { - AsyncImage( - model = row.url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(64.dp).clip(shape), - ) - } else { - Box( - modifier = Modifier.size(64.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = glyphFor(row.type), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } -} - private fun glyphFor(type: String?): MaterialSymbol = when { type?.startsWith("image/") == true -> MaterialSymbols.Image @@ -497,6 +695,18 @@ private fun glyphFor(type: String?): MaterialSymbol = else -> MaterialSymbols.Storage } +private fun shareUrl( + context: Context, + url: String, +) { + val send = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + runCatching { context.startActivity(Intent.createChooser(send, null)) } +} + @Composable private fun ServerPill(presence: ServerPresence) { val present = presence.state == PresenceState.PRESENT diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 282051e8b6..63c56df8e8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1672,6 +1672,7 @@ Shows progress while copying your files across your Blossom servers. No stored files found on your Blossom servers. Stored on + File details On all servers Not on all servers Mirror to missing