fix(marmot): route reply button on MLS messages to the encrypted group

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.
This commit is contained in:
Claude
2026-05-19 21:45:53 +00:00
parent d42482ff56
commit 2326738b84
10 changed files with 217 additions and 6 deletions
@@ -586,6 +586,8 @@ class EventNotificationConsumer(
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = null,
marmotNostrGroupId = nostrGroupId,
marmotReplyToInnerEventId = innerEvent.id,
)
}
@@ -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<NormalizedRelayUrl> =
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,
@@ -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)
}
@@ -368,7 +368,16 @@ fun BuildNavigation(
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> {
MarmotGroupChatScreen(
nostrGroupId = it.nostrGroupId,
draftMessage = it.message,
replyToInnerNote = it.replyId,
editFromDraft = it.draftId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
@@ -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 -> {
@@ -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(
@@ -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)
}
@@ -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,
)
@@ -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<Note?>(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<Note?> = 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) {
@@ -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<Event>(kind = 9, description = text)
.eventTemplate<Event>(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<Event>(signer.pubKey, template)