feat(marmot): edit your own messages in a Marmot group

Amethyst already applied kind-1009 edits from White Noise, but offered no way
to send one. My own text messages in a Marmot group now get the Edit tile in
the chat action sheet. The composer is prefilled with the text the message
currently shows (its latest edit, if any), an 'Editing message' banner offers
cancel, and sending publishes a kind-1009 edit through the same
show-then-publish path as a new message, so it overlays the bubble at once.
Media messages are excluded: their content is the attachment locator.

MarmotManager.buildMessageEditRumor splits the rumor out of buildMessageEdit
for callers that publish through their own path. The banner Concord used is
now the shared EditingMessageBanner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-27 23:43:32 -04:00
co-authored by Claude Opus 5.5
parent c2fda6aae2
commit 4ac9702e3b
7 changed files with 150 additions and 35 deletions
@@ -2473,6 +2473,23 @@ class AccountViewModel(
deliverMarmotGroupMessage(nostrGroupId, innerEvent)
}
/**
* Replace the text of my own Marmot message [target] with a kind:1009 edit. The edit
* overlays the original everywhere it is shown (here, and in White Noise), and goes
* through the same show-then-publish path as a new message, so a failed send shows on
* the edit's delivery state rather than silently.
*/
suspend fun sendMarmotGroupMessageEdit(
nostrGroupId: String,
target: Note,
text: String,
) {
val tagger = NewMessageTagger(text, null, null, this)
tagger.run()
val manager = account.marmotManager ?: return
deliverMarmotGroupMessage(nostrGroupId, manager.buildMessageEditRumor(target.idHex, tagger.message))
}
/**
* Show [innerEvent] in the group's chat now and publish it on the account
* scope. Shared by every Marmot send that originates in the UI.
@@ -289,7 +289,15 @@ fun ChatMessageActionSheet(
note.event is ChatEvent &&
isMine &&
note.inGatherers?.any { it is ConcordChannel } == true
if (canEditBuzz || canEditConcord) {
// Marmot: my own text message in a group -> a kind-1009 edit inside the group. A
// media message's content is its locator, so editing it would break the attachment.
val canEditMarmot =
onWantsToEditChatMessage != null &&
note.event is ChatEvent &&
isMine &&
note.event?.tags?.none { it.isNotEmpty() && it[0] == "imeta" } == true &&
accountViewModel.account.marmot.marmotGroupOf(note) != null
if (canEditBuzz || canEditConcord || canEditMarmot) {
SectionDivider()
TileRow {
val label = if (canEditBuzz) Res.string.buzz_edit_message else Res.string.edit_message
@@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
@@ -170,6 +171,7 @@ fun MarmotGroupChatView(
routeForLastRead = marmotGroupLastReadRoute(nostrGroupId),
onWantsToReply = { note -> newMessageModel.reply(note) },
onWantsToEditDraft = { },
onWantsToEditChatMessage = { note -> newMessageModel.editMarmotMessage(note) },
// kind:1210 rows sit in the conversation in order but are
// group-state captions rather than messages, so they get their
// own centered style instead of a bubble.
@@ -242,6 +244,10 @@ fun MarmotGroupMessageComposer(
}
}
newMessageModel.editingMessage.value?.let {
EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() })
}
Column(modifier = EditFieldModifier) {
newMessageModel.userSuggestions?.let {
ShowUserSuggestionList(
@@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
@@ -59,6 +60,9 @@ open class MarmotNewMessageViewModel : ViewModel() {
val message = TextFieldState()
val replyTo = mutableStateOf<Note?>(null)
// My own message being replaced; the next send publishes a kind:1009 edit of it.
val editingMessage = mutableStateOf<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
var userSuggestions: UserSuggestionState? = null
@@ -83,11 +87,26 @@ open class MarmotNewMessageViewModel : ViewModel() {
this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
this.message.clearText()
this.replyTo.value = null
this.editingMessage.value = null
}
}
fun reply(note: Note) {
replyTo.value = note
editingMessage.value = null
}
/** Enter edit mode for my own [note], prefilled with the text it currently shows. */
fun editMarmotMessage(note: Note) {
replyTo.value = null
editingMessage.value = note
val current = note.latestMarmotEdit()?.event?.content ?: note.event?.content ?: ""
message.setTextAndPlaceCursorAtEnd(current)
}
fun cancelEdit() {
editingMessage.value = null
message.clearText()
}
fun clearReply() {
@@ -137,6 +156,15 @@ open class MarmotNewMessageViewModel : ViewModel() {
val text = message.text.toString().trim()
if (text.isEmpty()) return
val editing = editingMessage.value
if (editing != null) {
accountViewModel.sendMarmotGroupMessageEdit(groupId, editing, text)
editingMessage.value = null
onUiThread { message.clearText() }
userSuggestions?.reset()
return
}
// Capture id+pubKey snapshot before suspending so a slow send
// doesn't race a user-cleared reply state.
val parentEvent = replyTo.value?.event
@@ -65,9 +65,7 @@ import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only
import com.vitorpamplona.amethyst.commons.resources.concord_editing_banner
import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
@@ -108,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.Ch
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
@@ -455,30 +454,7 @@ 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(Res.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(Res.string.cancel),
modifier = Modifier.size(16.dp),
)
}
}
EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() })
}
Column(modifier = EditFieldModifier) {
@@ -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.amethyst.ui.screen.loggedIn.chats.utils
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.concord_editing_banner
import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* Shown above a chat composer in edit mode: the next send replaces one of my messages
* instead of posting a new one. The X abandons the edit and clears the field.
*/
@Composable
fun EditingMessageBanner(onCancel: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(Res.string.concord_editing_banner),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
IconButton(onClick = onCancel) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(Res.string.cancel),
modifier = Modifier.size(16.dp),
)
}
}
}
@@ -82,6 +82,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
@@ -743,6 +744,21 @@ class MarmotManager(
/** The group's current MLS epoch, which the stream key context binds. */
fun currentEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
/**
* The kind:1009 edit rumor alone, for a caller that sends it through its own
* show-then-publish path (the app's composer) rather than [buildMessageEdit].
*/
fun buildMessageEditRumor(
targetEventId: HexKey,
replacement: String,
): Event {
val template =
eventTemplate<Event>(kind = MarmotAppEvent.KIND_EDIT, description = replacement) {
addUnique(arrayOf("e", targetEventId))
}
return RumorAssembler.assembleRumor(signer.pubKey, template)
}
/**
* Build a kind:1009 edit that replaces the text of a prior message.
*
@@ -762,14 +778,7 @@ class MarmotManager(
replacement: String,
persistOwn: Boolean = true,
): TextMessageBundle {
val template =
com.vitorpamplona.quartz.nip01Core.signers
.eventTemplate<Event>(kind = MarmotAppEvent.KIND_EDIT, description = replacement) {
addUnique(arrayOf("e", targetEventId))
}
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<Event>(signer.pubKey, template)
val innerEvent = buildMessageEditRumor(targetEventId, replacement)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)