diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9cd1d901b2..96eaab07b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2132,6 +2132,28 @@ class Account( // --- Marmot Group Messaging --- + /** + * Resolve the relay set for a Marmot group. Prefer the relays carried in + * the MLS GroupContext metadata so every member converges on the same + * canonical set; fall back to the account's outbox relays if the group + * has none (e.g. a group joined before MIP-01 metadata existed). + * + * Lives on Account (not AccountViewModel) so that headless callers — + * notifications' BroadcastReceiver, background workers — can resolve + * relays without spinning up a ViewModel. + */ + fun marmotGroupRelays(nostrGroupId: HexKey): Set { + val groupRelays = + marmotManager + ?.groupMetadata(nostrGroupId) + ?.relays + ?.mapNotNull { + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(it) + }?.toSet() + return if (!groupRelays.isNullOrEmpty()) groupRelays else outboxRelays.flow.value + } + /** * Send a message to a Marmot MLS group. * Encrypts the inner event and publishes the GroupEvent to group relays. 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 ebba761979..3a3b6bd37a 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,9 @@ class EventNotificationConsumer( accountNpub = accountNpub, accountPictureUrl = account.userProfile().profilePicture(), chatroomMembers = null, + marmotNostrGroupId = nostrGroupId, + marmotReplyToInnerEventId = innerEvent.id, + marmotReplyToInnerAuthor = innerEvent.pubKey, ) } 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 f8d8923afa..cb9b0377c6 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 @@ -94,6 +94,25 @@ 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) + val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR) + + runOnRelay(notificationManager, notificationId) { + sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText) + } + } } } @@ -140,6 +159,34 @@ class NotificationReplyReceiver : BroadcastReceiver() { account.sendNip17PrivateMessage(template) } + private suspend fun sendMarmotReply( + accountNpub: String, + nostrGroupId: String, + replyToInnerEventId: String?, + replyToInnerAuthor: String?, + replyText: String, + ) { + val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + + val manager = account.marmotManager ?: return + + // Use id+author from the Intent so the reply is threaded even when + // LocalCache hasn't been rehydrated yet (cold-process broadcast + // receiver: Account.restoreAll runs async on init and may not have + // finished by the time we get here). + val bundle = + manager.buildTextMessage( + nostrGroupId = nostrGroupId, + text = replyText, + replyToEventId = replyToInnerEventId, + replyToAuthorPubKey = replyToInnerAuthor, + persistOwn = false, + ) + + account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId)) + } + 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 7ff9d453f3..27b56d05bd 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,16 @@ 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" + const val KEY_MARMOT_REPLY_TO_INNER_AUTHOR = "key_marmot_reply_to_inner_author" private const val DM_SUMMARY_ID = 0x10000 private const val ZAP_SUMMARY_ID = 0x20000 @@ -374,6 +378,9 @@ object NotificationUtils { accountNpub: String? = null, accountPictureUrl: String? = null, chatroomMembers: String? = null, + marmotNostrGroupId: String? = null, + marmotReplyToInnerEventId: String? = null, + marmotReplyToInnerAuthor: String? = null, ) { getOrCreateDMChannel(applicationContext) val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id) @@ -390,6 +397,9 @@ object NotificationUtils { accountNpub = accountNpub, accountPictureUrl = accountPictureUrl, chatroomMembers = chatroomMembers, + marmotNostrGroupId = marmotNostrGroupId, + marmotReplyToInnerEventId = marmotReplyToInnerEventId, + marmotReplyToInnerAuthor = marmotReplyToInnerAuthor, ) } @@ -425,6 +435,9 @@ object NotificationUtils { accountNpub: String?, accountPictureUrl: String?, chatroomMembers: String?, + marmotNostrGroupId: String? = null, + marmotReplyToInnerEventId: String? = null, + marmotReplyToInnerAuthor: String? = null, ) { val notId = id.hashCode() @@ -522,6 +535,50 @@ 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) + } + if (marmotReplyToInnerAuthor != null) { + putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, marmotReplyToInnerAuthor) + } + } + + 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 15e9110a8f..7ad6288f6b 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,15 @@ 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, + 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 d38d7726b6..5612b80446 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 95766fb5a5..bc0c08c42b 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,8 @@ sealed class Route { @Serializable data class MarmotGroupChat( val nostrGroupId: String, + val message: String? = null, + val replyId: 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 7f2c0c0a4d..b695b85191 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,12 +1541,23 @@ class AccountViewModel( suspend fun sendMarmotGroupMessage( nostrGroupId: String, text: String, + replyToInnerEventId: HexKey? = null, + replyToInnerAuthorPubKey: HexKey? = 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 relays = marmotGroupRelays(nostrGroupId) + val bundle = + account.marmotManager + ?.buildTextMessage( + nostrGroupId = nostrGroupId, + text = text, + replyToEventId = replyToInnerEventId, + replyToAuthorPubKey = replyToInnerAuthorPubKey, + persistOwn = false, + ) + ?: return + val relays = account.marmotGroupRelays(nostrGroupId) account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays) } @@ -1576,7 +1587,7 @@ class AccountViewModel( account.signer.pubKey, template, ) - val relays = marmotGroupRelays(nostrGroupId) + val relays = account.marmotGroupRelays(nostrGroupId) account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) } @@ -1614,7 +1625,7 @@ class AccountViewModel( } suspend fun leaveMarmotGroup(nostrGroupId: String) { - val relays = marmotGroupRelays(nostrGroupId) + val relays = account.marmotGroupRelays(nostrGroupId) account.leaveMarmotGroup(nostrGroupId, relays) } @@ -1622,22 +1633,6 @@ class AccountViewModel( account.resetMarmotState() } - /** - * Get the relay set for a Marmot group from MLS GroupContext metadata. - * Falls back to outbox relays if the group has no configured relays. - */ - private fun marmotGroupRelays(nostrGroupId: String): Set { - val metadata = account.marmotManager?.groupMetadata(nostrGroupId) - val groupRelays = - metadata - ?.relays - ?.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer - .normalizeOrNull(it) - }?.toSet() - return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value - } - fun marmotGroupMembers(nostrGroupId: String): List = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList() suspend fun addMarmotGroupMember( @@ -1649,7 +1644,7 @@ class AccountViewModel( nostrGroupId: String, targetLeafIndex: Int, ) { - val relays = marmotGroupRelays(nostrGroupId) + val relays = account.marmotGroupRelays(nostrGroupId) account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) } @@ -1657,7 +1652,7 @@ class AccountViewModel( nostrGroupId: String, targetPubKey: String, ) { - val relays = marmotGroupRelays(nostrGroupId) + val relays = account.marmotGroupRelays(nostrGroupId) account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) } @@ -1665,7 +1660,7 @@ class AccountViewModel( nostrGroupId: String, targetPubKey: String, ) { - val relays = marmotGroupRelays(nostrGroupId) + val relays = account.marmotGroupRelays(nostrGroupId) account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) } @@ -1697,7 +1692,7 @@ class AccountViewModel( name = name, description = description, ) - val relays = marmotGroupRelays(nostrGroupId) + val relays = account.marmotGroupRelays(nostrGroupId) account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, 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 52a4a2d9a0..fe3413d65e 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,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun MarmotGroupChatScreen( nostrGroupId: HexKey, + draftMessage: String? = null, + replyToInnerNote: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -127,6 +129,8 @@ fun MarmotGroupChatScreen( Column(Modifier.padding(it)) { MarmotGroupChatView( nostrGroupId = nostrGroupId, + draftMessage = draftMessage, + replyToInnerNote = replyToInnerNote, 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 a0608a0cd9..b5936e8f2c 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,8 @@ import kotlinx.coroutines.launch @Composable fun MarmotGroupChatView( nostrGroupId: HexKey, + draftMessage: String? = null, + replyToInnerNote: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -99,6 +106,27 @@ 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) + } + } + Column(Modifier.fillMaxHeight()) { Column( modifier = @@ -111,7 +139,7 @@ fun MarmotGroupChatView( accountViewModel = accountViewModel, nav = nav, routeForLastRead = "MarmotGroup/$nostrGroupId", - onWantsToReply = { }, + onWantsToReply = { note -> replyTo.value = note }, onWantsToEditDraft = { }, ) } @@ -120,6 +148,8 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, + messageState = messageState, + replyTo = replyTo, accountViewModel = accountViewModel, nav = nav, onMessageSent = { @@ -132,12 +162,13 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, + messageState: TextFieldState, + replyTo: MutableState, 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 +193,12 @@ fun MarmotGroupMessageComposer( ) } + replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { + replyTo.value = null + } + } + Column(modifier = EditFieldModifier) { ThinPaddingTextField( state = messageState, @@ -191,10 +228,21 @@ fun MarmotGroupMessageComposer( ) { val text = messageState.text.toString().trim() if (text.isNotEmpty()) { + // Capture id+pubKey snapshot under the value? guard so + // a slow send doesn't race a user-cleared reply state. + val parentEvent = replyTo.value?.event + val replyId = parentEvent?.id + val replyAuthor = parentEvent?.pubKey scope.launch(Dispatchers.IO) { try { - accountViewModel.sendMarmotGroupMessage(nostrGroupId, text) + accountViewModel.sendMarmotGroupMessage( + nostrGroupId = nostrGroupId, + text = text, + replyToInnerEventId = replyId, + replyToInnerAuthorPubKey = replyAuthor, + ) 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 54a6851d4e..b4a0634e1f 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,30 @@ class MarmotManager( suspend fun buildTextMessage( nostrGroupId: HexKey, text: String, + replyToEventId: HexKey? = null, + replyToAuthorPubKey: HexKey? = null, persistOwn: Boolean = true, ): TextMessageBundle { val template = com.vitorpamplona.quartz.nip01Core.signers - .eventTemplate(kind = 9, description = text) + .eventTemplate(kind = 9, description = text) { + if (replyToEventId != null) { + // Mirror ChatEvent.reply(): NIP-18 q-tag references the + // parent inner kind:9 by id (+ optional author, no + // relay hint — the inner rumor never hits a relay + // directly). Taking id+pubKey separately (rather than + // the full parent Event) lets the push-notification + // reply path produce a threaded reply from cold start, + // when LocalCache hasn't been re-hydrated yet. + quote( + QEventTag( + eventId = replyToEventId, + relayHint = null, + authorPubKeyHex = replyToAuthorPubKey, + ), + ) + } + } val innerEvent = com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler .assembleRumor(signer.pubKey, template)