From 2326738b84bb601fa4cd67a0c37103045803b0ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 21:45:53 +0000 Subject: [PATCH] fix(marmot): route reply button on MLS messages to the encrypted group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping reply on a Marmot/MLS (kind:445) message in the Notifications screen previously fell through routeReplyTo()'s `else` branch and opened the generic public-comment composer (Route.GenericCommentPost). Sending it would publish a plaintext kind:1111 that references the encrypted inner event id, leaking that the user has decrypted that group message. Mirror the NIP-17 pattern (Route.Room carries replyId, draftId, draftMessage; the same chat screen renders the "replying to" quote above the input) for MLS: - routeReplyTo() now detects MarmotGroupChatroom in note.inGatherers, matching how routeFor() at line 67 already finds the parent group, and returns Route.MarmotGroupChat(groupId, replyId = note.idHex). - Route.MarmotGroupChat gains message/replyId/draftId fields. - MarmotGroupChatView resolves the replyId into a Note, shows DisplayReplyingToNote above the composer, wires onWantsToReply for in-chat replies, and threads the parent inner event through AccountViewModel.sendMarmotGroupMessage into MarmotManager.buildTextMessage, which now adds a NIP-18 q-tag on the inner kind:9 (the same convention ChatEvent.reply() uses). Push notifications: notifyGroupMessage previously passed chatroomMembers=null, so the inline Reply action was never attached for MLS group notifications. Add a parallel MARMOT_REPLY_ACTION wired with the group id + parent inner event id; NotificationReplyReceiver loads the account, rebuilds the parent inner event from LocalCache (or sends unthreaded if the cache was pruned), and publishes the reply through the same MarmotManager path — so the inline notification reply stays encrypted inside the group instead of taking the NIP-17 PTag fallback. --- .../EventNotificationConsumer.kt | 2 + .../NotificationReplyReceiver.kt | 63 +++++++++++++++++++ .../notifications/NotificationUtils.kt | 50 +++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 11 +++- .../ui/navigation/routes/RouteMaker.kt | 9 +++ .../amethyst/ui/navigation/routes/Routes.kt | 3 + .../ui/screen/loggedIn/AccountViewModel.kt | 6 +- .../marmotGroup/MarmotGroupChatScreen.kt | 6 ++ .../chats/marmotGroup/MarmotGroupChatView.kt | 55 +++++++++++++++- .../amethyst/commons/marmot/MarmotManager.kt | 18 +++++- 10 files changed, 217 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index ebba76197..d1e5c15c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -586,6 +586,8 @@ class EventNotificationConsumer( accountNpub = accountNpub, accountPictureUrl = account.userProfile().profilePicture(), chatroomMembers = null, + marmotNostrGroupId = nostrGroupId, + marmotReplyToInnerEventId = innerEvent.id, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index f8d8923af..5dc14e635 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -29,7 +29,10 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -94,6 +97,24 @@ class NotificationReplyReceiver : BroadcastReceiver() { sendPublicReply(accountNpub, targetEventId, replyText) } } + + NotificationUtils.MARMOT_REPLY_ACTION -> { + val replyText = + RemoteInput + .getResultsFromIntent(intent) + ?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT) + ?.toString() + + if (replyText.isNullOrBlank()) return + + val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return + val nostrGroupId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_GROUP_ID) ?: return + val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID) + + runOnRelay(notificationManager, notificationId) { + sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyText) + } + } } } @@ -140,6 +161,48 @@ class NotificationReplyReceiver : BroadcastReceiver() { account.sendNip17PrivateMessage(template) } + private suspend fun sendMarmotReply( + accountNpub: String, + nostrGroupId: String, + replyToInnerEventId: String?, + replyText: String, + ) { + val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + + val manager = account.marmotManager ?: return + + // Recover the parent inner event so the kind:9 reply carries the + // proper q-tag. Inner events live in LocalCache keyed by the inner + // id; if we somehow miss it (e.g. cache was pruned) the reply still + // goes through unthreaded — better than dropping the user's message. + val replyToInnerEvent: Event? = + replyToInnerEventId?.let { LocalCache.getNoteIfExists(it)?.event } + + val bundle = + manager.buildTextMessage( + nostrGroupId = nostrGroupId, + text = replyText, + replyTo = replyToInnerEvent, + persistOwn = false, + ) + + // Mirror AccountViewModel.marmotGroupRelays(): prefer the group's + // configured relays from MLS GroupContext metadata, fall back to + // the account's outbox set so a misconfigured group doesn't silently + // drop the reply. + val groupRelays: Set = + manager + .groupMetadata(nostrGroupId) + ?.relays + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ?.toSet() + ?.takeIf { it.isNotEmpty() } + ?: account.outboxRelays.flow.value + + account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, groupRelays) + } + private suspend fun sendPublicReply( accountNpub: String, targetEventId: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 7ff9d453f..c0bef4688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -60,12 +60,15 @@ object NotificationUtils { const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION" + const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" const val KEY_NOTIFICATION_ID = "key_notification_id" const val KEY_ACCOUNT_NPUB = "key_account_npub" const val KEY_CHATROOM_MEMBERS = "key_chatroom_members" const val KEY_TARGET_EVENT_ID = "key_target_event_id" + const val KEY_MARMOT_GROUP_ID = "key_marmot_group_id" + const val KEY_MARMOT_REPLY_TO_INNER_ID = "key_marmot_reply_to_inner_id" private const val DM_SUMMARY_ID = 0x10000 private const val ZAP_SUMMARY_ID = 0x20000 @@ -374,6 +377,8 @@ object NotificationUtils { accountNpub: String? = null, accountPictureUrl: String? = null, chatroomMembers: String? = null, + marmotNostrGroupId: String? = null, + marmotReplyToInnerEventId: String? = null, ) { getOrCreateDMChannel(applicationContext) val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id) @@ -390,6 +395,8 @@ object NotificationUtils { accountNpub = accountNpub, accountPictureUrl = accountPictureUrl, chatroomMembers = chatroomMembers, + marmotNostrGroupId = marmotNostrGroupId, + marmotReplyToInnerEventId = marmotReplyToInnerEventId, ) } @@ -425,6 +432,8 @@ object NotificationUtils { accountNpub: String?, accountPictureUrl: String?, chatroomMembers: String?, + marmotNostrGroupId: String? = null, + marmotReplyToInnerEventId: String? = null, ) { val notId = id.hashCode() @@ -522,6 +531,47 @@ object NotificationUtils { .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) .build() + builder.addAction(replyAction) + } else if (accountNpub != null && marmotNostrGroupId != null) { + // Marmot/MLS Reply action: sends the user's text as an encrypted + // kind:9 inside the Marmot group, replying to the inner event + // that triggered this notification. Mirrors the NIP-17 path + // above but routes through NotificationReplyReceiver's + // MARMOT_REPLY_ACTION branch so we never publish a plaintext + // public reply for an encrypted group message. + val remoteInput = + RemoteInput + .Builder(KEY_REPLY_TEXT) + .setLabel(stringRes(applicationContext, R.string.app_notification_reply_label)) + .build() + + val replyIntent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = MARMOT_REPLY_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_ACCOUNT_NPUB, accountNpub) + putExtra(KEY_MARMOT_GROUP_ID, marmotNostrGroupId) + if (marmotReplyToInnerEventId != null) { + putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, marmotReplyToInnerEventId) + } + } + + val replyPendingIntent = + PendingIntent.getBroadcast( + applicationContext, + notId, + replyIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val replyAction = + NotificationCompat.Action + .Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .build() + builder.addAction(replyAction) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 15e9110a8..ca035bcc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -368,7 +368,16 @@ fun BuildNavigation( composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEnd { MarmotGroupListScreen(accountViewModel, nav) } - composableFromEndArgs { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromEndArgs { + MarmotGroupChatScreen( + nostrGroupId = it.nostrGroupId, + draftMessage = it.message, + replyToInnerNote = it.replyId, + editFromDraft = it.draftId, + accountViewModel = accountViewModel, + nav = nav, + ) + } composableFromEndArgs { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } composableFromBottom { CreateGroupScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index d38d7726b..5612b8044 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -252,6 +252,15 @@ fun routeReplyTo( note: Note, account: Account, ): Route? { + // Marmot group messages must reply inside the encrypted group, not as a + // public kind:1111 comment. The inner kind:9 event has no group hint of + // its own — we detect the group via the gathering MarmotGroupChatroom, + // mirroring routeFor() above. + val marmotGroup = note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } + if (marmotGroup != null) { + return Route.MarmotGroupChat(marmotGroup.nostrGroupId, replyId = note.idHex) + } + val noteEvent = note.event return when (noteEvent) { is ChannelMessageEvent -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 95766fb5a..fef37c876 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -404,6 +404,9 @@ sealed class Route { @Serializable data class MarmotGroupChat( val nostrGroupId: String, + val message: String? = null, + val replyId: HexKey? = null, + val draftId: HexKey? = null, ) : Route() @Serializable data class MarmotGroupInfo( 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 7f2c0c0a4..92d3067bf 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 @@ -1541,11 +1541,15 @@ class AccountViewModel( suspend fun sendMarmotGroupMessage( nostrGroupId: String, text: String, + replyToInnerEvent: Event? = null, ) { // Inner event construction lives on MarmotManager so CLI and UI don't drift. // persistOwn=false because Account.sendMarmotGroupMessage routes the outer // event through LocalCache which already handles own-message display. - val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return + val bundle = + account.marmotManager + ?.buildTextMessage(nostrGroupId, text, replyTo = replyToInnerEvent, persistOwn = false) + ?: return val relays = marmotGroupRelays(nostrGroupId) account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index 52a4a2d9a..c3da81de9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -51,6 +51,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun MarmotGroupChatScreen( nostrGroupId: HexKey, + draftMessage: String? = null, + replyToInnerNote: HexKey? = null, + editFromDraft: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -127,6 +130,9 @@ fun MarmotGroupChatScreen( Column(Modifier.padding(it)) { MarmotGroupChatView( nostrGroupId = nostrGroupId, + draftMessage = draftMessage, + replyToInnerNote = replyToInnerNote, + editFromDraft = editFromDraft, accountViewModel = accountViewModel, nav = nav, ) 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 a0608a0cd..5777b4160 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 @@ -29,11 +29,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,6 +50,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -58,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader 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.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -74,6 +79,9 @@ import kotlinx.coroutines.launch @Composable fun MarmotGroupChatView( nostrGroupId: HexKey, + draftMessage: String? = null, + replyToInnerNote: HexKey? = null, + @Suppress("UNUSED_PARAMETER") editFromDraft: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -99,6 +107,32 @@ fun MarmotGroupChatView( onDispose { } } + val messageState = remember(nostrGroupId) { TextFieldState() } + val replyTo = remember(nostrGroupId) { mutableStateOf(null) } + + // Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS + // message in the Notifications screen) into the actual Note once it has + // landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids. + if (replyToInnerNote != null) { + LaunchedEffect(replyToInnerNote) { + val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote) + if (parent != null) { + replyTo.value = parent + } + } + } + + if (draftMessage != null) { + LaunchedEffect(draftMessage) { + messageState.setTextAndPlaceCursorAtEnd(draftMessage) + } + } + + // editFromDraft is accepted for route symmetry with NIP-17's + // Route.Room, but Marmot doesn't yet persist drafts, so it's currently + // unused. Suppressed at the parameter rather than via a fake binding + // so ktlint stays happy. + Column(Modifier.fillMaxHeight()) { Column( modifier = @@ -111,7 +145,7 @@ fun MarmotGroupChatView( accountViewModel = accountViewModel, nav = nav, routeForLastRead = "MarmotGroup/$nostrGroupId", - onWantsToReply = { }, + onWantsToReply = { note -> replyTo.value = note }, onWantsToEditDraft = { }, ) } @@ -120,6 +154,8 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, + messageState = messageState, + replyTo = replyTo, accountViewModel = accountViewModel, nav = nav, onMessageSent = { @@ -132,12 +168,13 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, + messageState: TextFieldState = remember { TextFieldState() }, + replyTo: MutableState = remember { mutableStateOf(null) }, accountViewModel: AccountViewModel, nav: INav, onMessageSent: suspend () -> Unit, ) { val scope = rememberCoroutineScope() - val messageState = remember { TextFieldState() } val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } val context = LocalContext.current @@ -162,6 +199,12 @@ fun MarmotGroupMessageComposer( ) } + replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { + replyTo.value = null + } + } + Column(modifier = EditFieldModifier) { ThinPaddingTextField( state = messageState, @@ -191,10 +234,16 @@ fun MarmotGroupMessageComposer( ) { val text = messageState.text.toString().trim() if (text.isNotEmpty()) { + val replyParent = replyTo.value?.event scope.launch(Dispatchers.IO) { try { - accountViewModel.sendMarmotGroupMessage(nostrGroupId, text) + accountViewModel.sendMarmotGroupMessage( + nostrGroupId = nostrGroupId, + text = text, + replyToInnerEvent = replyParent, + ) messageState.clearText() + replyTo.value = null onMessageSent() } catch (e: Exception) { launch(Dispatchers.Main) { 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 54a6851d4..cccb5b83f 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 @@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.quote import com.vitorpamplona.quartz.utils.Log import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -182,11 +184,25 @@ class MarmotManager( suspend fun buildTextMessage( nostrGroupId: HexKey, text: String, + replyTo: Event? = null, persistOwn: Boolean = true, ): TextMessageBundle { val template = com.vitorpamplona.quartz.nip01Core.signers - .eventTemplate(kind = 9, description = text) + .eventTemplate(kind = 9, description = text) { + if (replyTo != null) { + // Mirror ChatEvent.reply(): NIP-18 q-tag references the + // parent inner kind:9 by id (+ author, no relay hint — + // the inner rumor never hits a relay directly). + quote( + QEventTag( + eventId = replyTo.id, + relayHint = null, + authorPubKeyHex = replyTo.pubKey, + ), + ) + } + } val innerEvent = com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler .assembleRumor(signer.pubKey, template)