mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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 }))
|
||||
}
|
||||
+2
-2
@@ -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 }
|
||||
|
||||
+433
-100
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
-2
@@ -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)
|
||||
|
||||
+6
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+37
-15
@@ -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
|
||||
}
|
||||
|
||||
-24
@@ -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:
|
||||
|
||||
+87
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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(
|
||||
|
||||
+26
-2
@@ -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()
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
+71
-14
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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()
|
||||
|
||||
+5
-40
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+36
@@ -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].
|
||||
|
||||
+71
@@ -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)
|
||||
|
||||
+29
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user