feat: add @-mention user search and tagging to Marmot group chat composer

Brings the MLS/Marmot message composer to parity with NIP-17 DMs:
typing @ shows the shared user-suggestion dropdown (local cache +
NIP-05 resolution), selecting a user inserts @npub…, and on send
NewMessageTagger rewrites mentions into nostr: URIs and collects
the referenced users as p-tags on the inner kind:9 rumor. Mentions
stay inside the MLS ciphertext; the outer kind:445 is unchanged.

Also applies MentionPreservingInputTransformation and
UrlUserTagOutputTransformation to the field so mentions render
highlighted while composing, matching the DM editor.

https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n
This commit is contained in:
Claude
2026-06-10 15:35:03 +00:00
parent e41169efbf
commit 84e91833c4
3 changed files with 56 additions and 1 deletions
@@ -106,6 +106,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -1626,6 +1627,7 @@ class AccountViewModel(
text: String,
replyToInnerEventId: HexKey? = null,
replyToInnerAuthorPubKey: HexKey? = null,
mentions: List<PTag> = emptyList(),
) {
// Inner event construction lives on MarmotManager so CLI and UI don't drift.
// persistOwn=false because Account.sendMarmotGroupMessage routes the outer
@@ -1638,6 +1640,7 @@ class AccountViewModel(
replyToEventId = replyToInnerEventId,
replyToAuthorPubKey = replyToInnerAuthorPubKey,
persistOwn = false,
mentions = mentions,
)
?: return
val relays = account.marmotGroupRelays(nostrGroupId)
@@ -50,12 +50,18 @@ 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.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender
@@ -69,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
@@ -181,6 +188,15 @@ fun MarmotGroupMessageComposer(
)
}
val userSuggestions =
remember(nostrGroupId) {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
DisposableEffect(nostrGroupId) {
onDispose { userSuggestions.reset() }
}
// Upload dialog
uploadState.multiOrchestrator?.let {
MarmotGroupFileUploadDialog(
@@ -200,11 +216,33 @@ fun MarmotGroupMessageComposer(
}
Column(modifier = EditFieldModifier) {
ShowUserSuggestionList(
userSuggestions,
onSelect = { user ->
userSuggestions.replaceCurrentWord(messageState, messageState.currentWord(), user)
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightChat,
)
ThinPaddingTextField(
state = messageState,
onTextChanged = {
if (messageState.selection.collapsed) {
val lastWord = messageState.currentWord()
if (lastWord.startsWith("@")) {
userSuggestions.processCurrentWord(lastWord)
} else {
userSuggestions.reset()
}
}
},
onContentReceived = { uri, mimeType ->
uploadState.load(persistentListOf(SelectedMedia(uri, mimeType)))
},
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
@@ -235,11 +273,16 @@ fun MarmotGroupMessageComposer(
val replyAuthor = parentEvent?.pubKey
scope.launch(Dispatchers.IO) {
try {
// Rewrites @npub…/@nprofile… mentions into nostr: URIs and
// collects the referenced users as p-tags for the inner event.
val tagger = NewMessageTagger(text, null, null, accountViewModel)
tagger.run()
accountViewModel.sendMarmotGroupMessage(
nostrGroupId = nostrGroupId,
text = text,
text = tagger.message,
replyToInnerEventId = replyId,
replyToInnerAuthorPubKey = replyAuthor,
mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(),
)
messageState.clearText()
replyTo.value = null
@@ -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.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
import com.vitorpamplona.quartz.utils.Log
@@ -178,6 +180,11 @@ class MarmotManager(
* `persistOwn = false`. Headless callers (CLI) should leave it at
* the default.
*
* [mentions] become p-tags on the inner kind:9 (users referenced via
* `nostr:npub…`/`nostr:nprofile…` in [text]), mirroring how NIP-17
* chat messages tag mentioned users. They stay inside the MLS
* ciphertext — the outer kind:445 never carries member pubkeys.
*
* @return the signed kind:445 outer event together with the inner kind:9
* rumor id, so the caller can reference it for replies/reactions.
*/
@@ -187,10 +194,12 @@ class MarmotManager(
replyToEventId: HexKey? = null,
replyToAuthorPubKey: HexKey? = null,
persistOwn: Boolean = true,
mentions: List<PTag> = emptyList(),
): TextMessageBundle {
val template =
com.vitorpamplona.quartz.nip01Core.signers
.eventTemplate<Event>(kind = 9, description = text) {
pTags(mentions)
if (replyToEventId != null) {
// Mirror ChatEvent.reply(): NIP-18 q-tag references the
// parent inner kind:9 by id (+ optional author, no