mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
co-authored by
Claude Opus 5.5
parent
c2fda6aae2
commit
4ac9702e3b
+17
@@ -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.
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
+6
@@ -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(
|
||||
|
||||
+28
@@ -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
|
||||
|
||||
+2
-26
@@ -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) {
|
||||
|
||||
+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.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user