Merge pull request #2995 from vitorpamplona/claude/test-mls-reply-notifications-SnaIM

Add reply support for Marmot/MLS group messages
This commit is contained in:
Vitor Pamplona
2026-05-19 18:07:17 -04:00
committed by GitHub
11 changed files with 245 additions and 29 deletions
@@ -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<NormalizedRelayUrl> {
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.
@@ -586,6 +586,9 @@ class EventNotificationConsumer(
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = null,
marmotNostrGroupId = nostrGroupId,
marmotReplyToInnerEventId = innerEvent.id,
marmotReplyToInnerAuthor = innerEvent.pubKey,
)
}
@@ -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,
@@ -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)
}
@@ -368,7 +368,15 @@ 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,
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,8 @@ sealed class Route {
@Serializable data class MarmotGroupChat(
val nostrGroupId: String,
val message: String? = null,
val replyId: HexKey? = null,
) : Route()
@Serializable data class MarmotGroupInfo(
@@ -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<NormalizedRelayUrl> {
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<com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo> = 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)
}
@@ -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,
)
@@ -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<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)
}
}
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<Note?>,
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) {
@@ -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<Event>(kind = 9, description = text)
.eventTemplate<Event>(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<Event>(signer.pubKey, template)