diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 6439cc7e86..f49fcd97f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index bf0bbd0eb9..85cdabb4ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 6c5f014601..399c647b78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt index fd5804fc87..36e1beb9ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -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(null) + // My own message being replaced; the next send publishes a kind:1009 edit of it. + val editingMessage = mutableStateOf(null) + var uploadState by mutableStateOf(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 81f5853241..47838e144b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt new file mode 100644 index 0000000000..a000abf5ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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), + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 2b59649dcc..da70cac22b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -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(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(kind = MarmotAppEvent.KIND_EDIT, description = replacement) { - addUnique(arrayOf("e", targetEventId)) - } - val innerEvent = - com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler - .assembleRumor(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)