Merge remote-tracking branch 'origin/main' into claude/chat-picture-sending-consistency-qit0pz

# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt
This commit is contained in:
Claude
2026-07-24 22:05:36 +00:00
24 changed files with 1087 additions and 273 deletions
@@ -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);
@@ -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<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? = modificationCache[note.idHex]
fun findLatestModificationForNote(note: Note): List<Note> {
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<Note>) {
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)
}
@@ -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<Note> {
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 }))
}
@@ -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 }
@@ -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<HexKey?>(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"
@@ -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)
},
)
}
@@ -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)
@@ -80,7 +80,7 @@ fun RefreshingChatroomFeedView(
// callers with no external jump affordance.
jumpToNoteId: State<String?>? = 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<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = 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<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = 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,
)
}
}
@@ -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()
}
}
@@ -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<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value = note.latestConcordEdit() ?: note.latestBuzzEdit()
}
}
return latest
}
@@ -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:
@@ -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<Color>,
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,
)
}
}
@@ -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(
@@ -63,6 +63,10 @@ open class ConcordNewMessageViewModel : ViewModel() {
val message = TextFieldState()
val replyTo = mutableStateOf<Note?>(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<Note?>(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()
@@ -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.
+7
View File
@@ -1673,6 +1673,10 @@
<string name="blossom_sync_channel_name">Blossom sync</string>
<string name="blossom_sync_channel_description">Shows progress while copying your files across your Blossom servers.</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_stored_on">Stored on</string>
<string name="blossom_file_details">File details</string>
<string name="blossom_on_all_servers">On all servers</string>
<string name="blossom_not_on_all_servers">Not on all servers</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>
<string name="blossom_delete_from">Delete from…</string>
<string name="blossom_delete_from_host">Delete from %1$s</string>
@@ -3387,6 +3391,9 @@
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_edit_message">Edit</string>
<string name="buzz_editing_banner">Editing message</string>
<string name="edit_message">Edit</string>
<string name="message_edited">(edited)</string>
<string name="concord_editing_banner">Editing message</string>
<string name="buzz_typing_one">%1$s is typing…</string>
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
<string name="buzz_typing_many">Several people are typing…</string>
@@ -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())
}
}
@@ -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<Array<String>> = 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,
@@ -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<Note>()
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<Note>()
private set
var reports = mapOf<User, List<Note>>()
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()
@@ -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<HexKey, Note>()
private val editVersion = MutableStateFlow(0)
/** Bumps when any overlay entry changes, so rows re-read [editFor]. */
val editUpdates: StateFlow<Int> = 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<Int> = 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<HexKey>) =
lock.withLock {
val dead = editsByTarget.keys().filter { it !in aliveTargetIds }
if (dead.isNotEmpty()) {
dead.forEach { editsByTarget.remove(it) }
editVersion.value = editVersion.value + 1
}
}
}
/**
@@ -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<Array<String>> = emptyArray(),
): Event =
RumorAssembler.assembleRumor<ConcordChatEditEvent>(
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].
@@ -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", <id>]` 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<Array<String>>,
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
}
}
@@ -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)
@@ -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 {