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 44bbedb304..ec5436fbc9 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 @@ -2381,6 +2382,37 @@ class Account( return true } + /** + * Edit my own Concord channel message [note] to [newText]. Mirrors + * [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 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. + */ + 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/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 08b63350d2..e6775f3860 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 @@ -134,6 +133,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 @@ -2229,15 +2229,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) } } @@ -2764,12 +2763,48 @@ 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 + // (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)?.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) + + return true + } + + 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()) + + // 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) @@ -3205,34 +3240,6 @@ object LocalCache : ILocalCache, ICacheProvider { return minTime } - val modificationCache = LruCache>(20) - - fun cachedModificationEventsForNote(note: Note): List? = modificationCache[note.idHex] - - 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 - } - fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() @@ -3323,13 +3330,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", @@ -3556,6 +3556,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 -> @@ -3594,6 +3599,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) } } @@ -4903,6 +4917,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/model/NoteEditOverlays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/NoteEditOverlays.kt new file mode 100644 index 0000000000..a460743c5b --- /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 } + // 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. */ +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 })) +} 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/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 69afaddab3..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,8 +20,10 @@ */ 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 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,30 +32,43 @@ 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.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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +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 +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.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 @@ -70,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 @@ -86,8 +108,11 @@ 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 +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable @Composable fun BlossomBlobManagerScreen( @@ -104,6 +129,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 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 -> BlossomPaymentDialog( host = pending.targetHost, @@ -114,6 +144,31 @@ fun BlossomBlobManagerScreen( ) } + selectedHash?.let { hash -> + val selected = blobs.firstOrNull { it.hash == hash } + 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 }, + ) + } + } + Scaffold( topBar = { TopBarExtensibleWithBackButton( @@ -164,16 +219,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 }) } } } @@ -181,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( @@ -229,13 +292,277 @@ private fun SyncAllBanner(onSyncAll: () -> Unit) { } } -@OptIn(ExperimentalLayoutApi::class) +/** + * 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 BlobCard( +private fun GalleryTile( + row: BlobRow, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable(onClick = onClick), + ) { + BlobPreview(row = row, modifier = Modifier.fillMaxSize()) + + SyncBadge( + row = row, + modifier = Modifier.align(Alignment.TopEnd).padding(6.dp), + ) + } +} + +/** + * 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 + * 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, + ) + } +} + +/** + * 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, + vm: BlossomBlobManagerViewModel, + 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 menuOpen by remember { mutableStateOf(false) } var reportOpen by remember { mutableStateOf(false) } val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() @@ -243,17 +570,26 @@ private fun BlobCard( Column( modifier = - Modifier + modifier .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // Header: thumbnail / file glyph + hash + overflow menu. + // Preview + identity. Row(verticalAlignment = Alignment.CenterVertically) { - BlobThumbnail(row) - Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) { + 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) + } + Column(modifier = Modifier.weight(1f).padding(start = 14.dp)) { Text( text = row.hash.take(12) + "…" + row.hash.takeLast(6), style = MaterialTheme.typography.titleSmall, @@ -267,62 +603,24 @@ private fun BlobCard( 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) - }, - ) - } - } - } + // 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) } } } - // 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. + // Primary CTA: fill the gaps for this file. if (row.hasMissing && row.url != null) { FilledTonalButton( onClick = { vm.mirrorToMissing(row) }, @@ -333,6 +631,34 @@ private fun BlobCard( Text(stringRes(R.string.blossom_mirror_to_missing)) } } + + // 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())) } + } + } + + 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) } + } + } } if (reportOpen) { @@ -341,31 +667,46 @@ private fun BlobCard( } @Composable -private fun BlobThumbnail(row: BlobRow) { - val shape = RoundedCornerShape(12.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), - ) - } else { - Box( - modifier = Modifier.size(48.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, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } +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) } } +private fun glyphFor(type: String?): MaterialSymbol = + when { + type?.startsWith("image/") == true -> MaterialSymbols.Image + type?.startsWith("video/") == true -> MaterialSymbols.PlayCircle + 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 @@ -396,14 +737,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/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 7211be222f..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 @@ -2060,18 +2061,16 @@ fun observeEdits( val editState = remember(baseNote.idHex) { - val cached = accountViewModel.cachedModificationEventsForNote(baseNote) + // 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 = baseNote.textNoteModifications() 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..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) = LocalCache.cachedModificationEventsForNote(note) - fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key) fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key) 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 e1f14b5bce..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..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 @@ -65,18 +67,20 @@ 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 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.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 @@ -114,9 +118,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 +176,7 @@ fun ChatroomMessageCompose( onHighlightFinished, groupPosition, previousNoteId, - onWantsToEditBuzz, + onWantsToEditChatMessage, ) } } @@ -202,7 +207,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 +338,7 @@ fun NormalChatNote( onDismiss = onDismiss, accountViewModel = accountViewModel, nav = nav, - onWantsToEditBuzz = onWantsToEditBuzz, + onWantsToEditChatMessage = onWantsToEditChatMessage, ) }, reactionsRow = @@ -612,16 +617,33 @@ 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. - val buzzEdit = observeBuzzEdit(note) - if (buzzEdit != null) { - RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav) - } else { - RenderRegularTextNote(note, 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 d20e48679d..0cc1cbd6f9 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.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -41,7 +39,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 @@ -63,27 +60,6 @@ import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent 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. - * - * 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. - */ -@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) } -} /** * A Buzz stream message whose content has been superseded by a kind-40003 edit: 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..5004e87e6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderConcordEdits.kt @@ -0,0 +1,87 @@ +/* + * 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.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.toImmutableListOfLists +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 + +/** + * A Concord chat message whose content has been superseded by a kind-3302 edit: + * renders the NEWEST edit's content (never the stale original) plus an "(edited)" + * marker, matching the Concord reference client's last-write-wins presentation. + */ +@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 6962647b2b..160acdf42a 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 @@ -193,6 +194,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 = { @@ -410,6 +412,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 0e04dfe5d0..042aac4fb9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1673,6 +1673,10 @@ Blossom sync 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 Delete from… Delete from %1$s @@ -3387,6 +3391,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/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 3b149f1359..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 @@ -48,7 +49,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 +148,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 +160,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 +174,64 @@ 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 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", + 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", 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 { val channelId = newChannelId() val original = streamMessage(channelId, "will be pruned") @@ -183,11 +239,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/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 10e9467de9..fa23a7ee33 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 @@ -274,6 +274,25 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * 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( + 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/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..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) @@ -187,6 +188,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 +410,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 +436,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 +444,7 @@ open class Note( replies + reactions.values.flatten() + boosts + + edits + reports.values.flatten() + labels.values.flatten() + zaps.keys + @@ -429,6 +457,7 @@ open class Note( replies = listOf() reactions = mapOf() boosts = listOf() + edits = listOf() reports = mapOf() labels = mapOf() zaps = mapOf() @@ -442,6 +471,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() 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 - } - } } /** 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 c1e83a7166..42ae4f03ea 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 @@ -102,6 +102,42 @@ object ChannelChat { extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags, ) + /** + * Builds an unsigned kind-3302 [ConcordChatEditEvent] rumor that edits an existing + * channel message [targetId] with [newText], bound to [channelId]/[epoch]. + * + * 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, + channelId: HexKey, + epoch: Long, + targetId: HexKey, + newText: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = ConcordChatEditEvent.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/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 3bf210b467..5e9e99de77 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,35 @@ class ChannelChatEndToEndTest { assertTrue(ChannelChat.isBoundTo(thread, channelIdHex, 0L)) } + @Test + fun editIsAChannelBoundKind3302ThatPointsAtTheTargetAndRoundTrips() = + 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) + + // 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; 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(3302, opened.rumor.kind) + assertEquals("hello", opened.rumor.content) + assertEquals(alice.pubKey, opened.author) + assertEquals(original.id, (opened.rumor as ConcordChatEditEvent).editedMessageId()) + } + @Test fun typingHeartbeatIsAnEphemeralWrapReadableByAnotherMember() = runTest {