From 84e91833c4141d3ab5a7878c0c68beee9ef25a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:35:03 +0000 Subject: [PATCH 1/6] feat: add @-mention user search and tagging to Marmot group chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/screen/loggedIn/AccountViewModel.kt | 3 ++ .../chats/marmotGroup/MarmotGroupChatView.kt | 45 ++++++++++++++++++- .../amethyst/commons/marmot/MarmotManager.kt | 9 ++++ 3 files changed, 56 insertions(+), 1 deletion(-) 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 dcfca17eca..b0b62c39e7 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 @@ -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 = 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) 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 b5936e8f2c..16952c4a7f 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 @@ -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 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 b4a0634e1f..12264f99e5 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.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 = emptyList(), ): TextMessageBundle { val template = com.vitorpamplona.quartz.nip01Core.signers .eventTemplate(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 From bf0fa0c57ecf2df5ec652b67390db3f68bb1edf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:34:09 +0000 Subject: [PATCH 2/6] feat: rank conversation participants first in @-mention suggestions Adds an optional priorityPubkeys supplier to UserSuggestionState that stable-sorts search results so the current conversation's participants appear before network-wide matches (ranking only, never filters). Wired per chat context: - MLS/Marmot groups: live MLS member list - NIP-17 DMs/groups: the room's users - Public chats (NIP-28): authors who have posted in the channel - Nests audio rooms: MeetingSpaceEvent participants + host https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n --- .../userSuggestions/UserSuggestionState.kt | 19 ++++++++++++++++++- .../chats/marmotGroup/MarmotGroupChatView.kt | 7 ++++++- .../privateDM/send/ChatNewMessageViewModel.kt | 7 ++++++- .../send/ChannelNewMessageViewModel.kt | 9 ++++++++- .../room/chat/NestNewMessageViewModel.kt | 11 ++++++++++- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index f066303433..22b4ef5515 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client @@ -58,10 +59,20 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** + * Drives the @-mention autocomplete dropdown: searches the local cache, + * relays, and NIP-05 identifiers for the word currently being typed. + * + * [priorityPubkeys] is a live supplier of pubkeys to rank first in the + * results — pass the current conversation's participants (NIP-17 room + * users, public-chat authors, MLS group members, …) so they beat + * network-wide matches. Ranking only; it never filters anyone out. + */ @Stable class UserSuggestionState( val account: Account, val nip05Client: INip05Client, + val priorityPubkeys: () -> Set = { emptySet() }, ) { val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") @@ -158,7 +169,13 @@ class UserSuggestionState( } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { - account.cache.findUsersStartingWith(prefix, account) + val found = account.cache.findUsersStartingWith(prefix, account) + val priority = priorityPubkeys() + if (priority.isEmpty()) { + found + } else { + found.sortedByDescending { it.pubkeyHex in priority } + } } } else { emptyList() 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 16952c4a7f..b9b0f2d1c7 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 @@ -190,7 +190,12 @@ fun MarmotGroupMessageComposer( val userSuggestions = remember(nostrGroupId) { - UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + val group = accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) + UserSuggestionState( + accountViewModel.account, + accountViewModel.nip05ClientBuilder(), + priorityPubkeys = { group.members.value.mapTo(mutableSetOf()) { it.pubkey } }, + ) } DisposableEffect(nostrGroupId) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 45d481b0af..8bbc1f2fe6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -261,7 +261,12 @@ class ChatNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { room.value?.users ?: emptySet() }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index f826189c6d..0364ce4dcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -188,7 +188,14 @@ open class ChannelNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + channel?.participatingAuthors(0)?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 4357b1eda9..878db35bce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -194,7 +194,16 @@ open class NestNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + (room?.event as? MeetingSpaceEvent)?.let { space -> + space.participantKeys().toMutableSet().apply { add(space.pubKey) } + } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) From b79ab1214c1fa8ffab31be66529e60b163fec006 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:24:27 +0000 Subject: [PATCH 3/6] refactor: address review findings on mention tagging and suggestions - Move NewMessageTagger from the Marmot composer into AccountViewModel.sendMarmotGroupMessage so every send path gets mention rewriting + p-tagging, not just the chat composer. - Pass the parent's MarmotGroupChatroom into the composer instead of re-fetching it from the group list. - Bound the public-channel participant scan with a one-month cutoff (matches the recency-cutoff convention in ChannelObservers). - Simplify the nests participant-set construction. --- .../ui/screen/loggedIn/AccountViewModel.kt | 12 ++++++++---- .../chats/marmotGroup/MarmotGroupChatView.kt | 14 +++++--------- .../send/ChannelNewMessageViewModel.kt | 4 +++- .../nests/room/chat/NestNewMessageViewModel.kt | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) 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 b0b62c39e7..fc1b395991 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 @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -106,7 +107,6 @@ 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 @@ -1627,8 +1627,12 @@ class AccountViewModel( text: String, replyToInnerEventId: HexKey? = null, replyToInnerAuthorPubKey: HexKey? = null, - mentions: List = emptyList(), ) { + // Rewrites @npub…/@nprofile… mentions into nostr: URIs and collects + // the referenced users as p-tags. Lives here (not in the composer) so + // every send path gets mention handling. + val tagger = NewMessageTagger(text, null, null, this) + tagger.run() // 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. @@ -1636,11 +1640,11 @@ class AccountViewModel( account.marmotManager ?.buildTextMessage( nostrGroupId = nostrGroupId, - text = text, + text = tagger.message, replyToEventId = replyToInnerEventId, replyToAuthorPubKey = replyToInnerAuthorPubKey, persistOwn = false, - mentions = mentions, + mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) ?: return val relays = account.marmotGroupRelays(nostrGroupId) 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 b9b0f2d1c7..d548c1c5c9 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 @@ -50,10 +50,10 @@ 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.model.marmotGroups.MarmotGroupChatroom 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 @@ -155,6 +155,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, + chatroom = chatroom, messageState = messageState, replyTo = replyTo, accountViewModel = accountViewModel, @@ -169,6 +170,7 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, + chatroom: MarmotGroupChatroom, messageState: TextFieldState, replyTo: MutableState, accountViewModel: AccountViewModel, @@ -190,11 +192,10 @@ fun MarmotGroupMessageComposer( val userSuggestions = remember(nostrGroupId) { - val group = accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) UserSuggestionState( accountViewModel.account, accountViewModel.nip05ClientBuilder(), - priorityPubkeys = { group.members.value.mapTo(mutableSetOf()) { it.pubkey } }, + priorityPubkeys = { chatroom.members.value.mapTo(mutableSetOf()) { it.pubkey } }, ) } @@ -278,16 +279,11 @@ 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 = tagger.message, + text = text, replyToInnerEventId = replyId, replyToInnerAuthorPubKey = replyAuthor, - mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) messageState.clearText() replyTo.value = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 0364ce4dcc..6a89bb8fe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -193,7 +193,9 @@ open class ChannelNewMessageViewModel : accountVM.account, accountVM.nip05ClientBuilder(), priorityPubkeys = { - channel?.participatingAuthors(0)?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() + // Public channels have no membership; recent posters are the + // closest thing. The cutoff also bounds the note scan. + channel?.participatingAuthors(TimeUtils.oneMonthAgo())?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 878db35bce..a9e7993a97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -200,7 +200,7 @@ open class NestNewMessageViewModel : accountVM.nip05ClientBuilder(), priorityPubkeys = { (room?.event as? MeetingSpaceEvent)?.let { space -> - space.participantKeys().toMutableSet().apply { add(space.pubKey) } + space.participantKeys().toSet() + space.pubKey } ?: emptySet() }, ) From 6f797798850f710d6ef9d4135cba94bb34699d2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:40:52 +0000 Subject: [PATCH 4/6] feat: mark conversation participants with an 'In this chat' chip in mention suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a small chip on suggestion rows whose pubkey is in the suggestion state's priorityPubkeys set, unless the caller supplies its own trailingContent. Priority keys only reorder and label the users that already matched the search — they never inject results. --- .../userSuggestions/ShowUserSuggestionList.kt | 33 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index cfa7304b62..dc8a448dce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +46,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User @@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize @@ -116,13 +121,23 @@ fun WatchResponses( val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) if (suggestions.isNotEmpty()) { + // Snapshot once per result list, not per row. + val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } + LazyColumn( contentPadding = PaddingValues(top = 10.dp), modifier = modifier, state = listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - UserLine(item, accountViewModel, trailingContent) { onSelect(item) } + val trailing = + trailingContent + ?: if (item.pubkeyHex in priority) { + { InThisChatChip() } + } else { + null + } + UserLine(item, accountViewModel, trailing) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, ) @@ -133,6 +148,22 @@ fun WatchResponses( } } +@Composable +private fun InThisChatChip() { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringRes(R.string.user_suggestion_in_this_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + @Composable fun UserLine( baseUser: User, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5845409944..ad5044f7e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -214,6 +214,7 @@ Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners. User does not have a lightning address set up to receive sats "reply here… " + In this chat Copies the Note ID to the clipboard for sharing in Nostr Copy Channel ID (Note) to the Clipboard Edits the Channel Metadata From fde5818044c41fd25bf5ebd76f1b3aa72459b27d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:43:30 +0000 Subject: [PATCH 5/6] refactor: extract MarmotNewMessageViewModel for the MLS composer Moves the Marmot composer's inline state (message TextFieldState, reply state, upload state, @-mention suggestion wiring, send) into a ViewModel mirroring ChatNewMessageViewModel / ChannelNewMessageViewModel / NestNewMessageViewModel, so all four chat types share the same init/load structure. No behavior change. --- .../chats/marmotGroup/MarmotGroupChatView.kt | 154 ++++++------------ .../send/MarmotNewMessageViewModel.kt | 147 +++++++++++++++++ 2 files changed, 196 insertions(+), 105 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt 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 d548c1c5c9..24d1dc92d6 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 @@ -27,16 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight 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 @@ -50,9 +46,6 @@ 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.model.marmotGroups.MarmotGroupChatroom -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.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -61,11 +54,11 @@ 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 import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel 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 @@ -103,19 +96,15 @@ fun MarmotGroupChatView( WatchLifecycleAndUpdateModel(feedViewModel) - val chatroom = - remember(nostrGroupId) { - accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) - } + val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel") + newMessageModel.init(accountViewModel) + newMessageModel.load(nostrGroupId) DisposableEffect(nostrGroupId) { - chatroom.markAsRead() + newMessageModel.chatroom?.markAsRead() 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. @@ -123,14 +112,14 @@ fun MarmotGroupChatView( LaunchedEffect(replyToInnerNote) { val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote) if (parent != null) { - replyTo.value = parent + newMessageModel.reply(parent) } } } if (draftMessage != null) { LaunchedEffect(draftMessage) { - messageState.setTextAndPlaceCursorAtEnd(draftMessage) + newMessageModel.editFromDraft(draftMessage) } } @@ -146,7 +135,7 @@ fun MarmotGroupChatView( accountViewModel = accountViewModel, nav = nav, routeForLastRead = "MarmotGroup/$nostrGroupId", - onWantsToReply = { note -> replyTo.value = note }, + onWantsToReply = { note -> newMessageModel.reply(note) }, onWantsToEditDraft = { }, ) } @@ -155,9 +144,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, - chatroom = chatroom, - messageState = messageState, - replyTo = replyTo, + newMessageModel = newMessageModel, accountViewModel = accountViewModel, nav = nav, onMessageSent = { @@ -170,82 +157,56 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, - chatroom: MarmotGroupChatroom, - messageState: TextFieldState, - replyTo: MutableState, + newMessageModel: MarmotNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, onMessageSent: suspend () -> Unit, ) { val scope = rememberCoroutineScope() - val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } + val canPost by remember { derivedStateOf { newMessageModel.canPost() } } val context = LocalContext.current var isUploading by remember { mutableStateOf(false) } - val uploadState = - remember { - ChatFileUploadState( - defaultServer = accountViewModel.account.settings.defaultFileServer, - defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload, - ) - } - - val userSuggestions = - remember(nostrGroupId) { - UserSuggestionState( - accountViewModel.account, - accountViewModel.nip05ClientBuilder(), - priorityPubkeys = { chatroom.members.value.mapTo(mutableSetOf()) { it.pubkey } }, - ) - } DisposableEffect(nostrGroupId) { - onDispose { userSuggestions.reset() } + onDispose { newMessageModel.userSuggestions?.reset() } } // Upload dialog - uploadState.multiOrchestrator?.let { - MarmotGroupFileUploadDialog( - nostrGroupId = nostrGroupId, - state = uploadState, - accountViewModel = accountViewModel, - nav = nav, - onUpload = { onMessageSent() }, - onCancel = uploadState::reset, - ) + newMessageModel.uploadState?.let { uploadState -> + uploadState.multiOrchestrator?.let { + MarmotGroupFileUploadDialog( + nostrGroupId = nostrGroupId, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } } - replyTo.value?.let { + newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { - replyTo.value = null + newMessageModel.clearReply() } } Column(modifier = EditFieldModifier) { - ShowUserSuggestionList( - userSuggestions, - onSelect = { user -> - userSuggestions.replaceCurrentWord(messageState, messageState.currentWord(), user) - userSuggestions.reset() - }, - accountViewModel = accountViewModel, - modifier = SuggestionListDefaultHeightChat, - ) + newMessageModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + newMessageModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, + ) + } ThinPaddingTextField( - state = messageState, - onTextChanged = { - if (messageState.selection.collapsed) { - val lastWord = messageState.currentWord() - if (lastWord.startsWith("@")) { - userSuggestions.processCurrentWord(lastWord) - } else { - userSuggestions.reset() - } - } - }, + state = newMessageModel.message, + onTextChanged = { newMessageModel.onMessageChanged() }, onContentReceived = { uri, mimeType -> - uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) + newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType))) }, inputTransformation = MentionPreservingInputTransformation, outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), @@ -260,9 +221,7 @@ fun MarmotGroupMessageComposer( leadingIcon = { MarmotGalleryLeadingIcon( isUploading = isUploading, - onImageChosen = { selectedMedia -> - uploadState.load(selectedMedia) - }, + onImageChosen = newMessageModel::pickedMedia, ) }, trailingIcon = { @@ -270,33 +229,18 @@ fun MarmotGroupMessageComposer( isActive = canPost, modifier = EditFieldTrailingIconModifier, ) { - 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 = nostrGroupId, - text = text, - replyToInnerEventId = replyId, - replyToInnerAuthorPubKey = replyAuthor, - ) - messageState.clearText() - replyTo.value = null - onMessageSent() - } catch (e: Exception) { - launch(Dispatchers.Main) { - Toast - .makeText( - context, - "Failed to send message: ${e.message}", - Toast.LENGTH_SHORT, - ).show() - } + scope.launch(Dispatchers.IO) { + try { + newMessageModel.sendPost() + onMessageSent() + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to send message: ${e.message}", + Toast.LENGTH_SHORT, + ).show() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt new file mode 100644 index 0000000000..d5501a39cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +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.utils.ChatFileUploadState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList + +/** + * Composition state for the Marmot/MLS group message field, mirroring the + * structure of the other chat composers (ChatNewMessageViewModel, + * ChannelNewMessageViewModel, NestNewMessageViewModel): @-mention + * suggestions, reply state, and file-upload state. Sending goes through + * AccountViewModel.sendMarmotGroupMessage, which owns mention tagging. + */ +@Stable +open class MarmotNewMessageViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var nostrGroupId: HexKey? = null + var chatroom: MarmotGroupChatroom? = null + + val message = TextFieldState() + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + var userSuggestions: UserSuggestionState? = null + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + + this.userSuggestions?.reset() + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { chatroom?.members?.value?.mapTo(mutableSetOf()) { it.pubkey } ?: emptySet() }, + ) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + open fun load(nostrGroupId: HexKey) { + if (this.nostrGroupId != nostrGroupId) { + this.nostrGroupId = nostrGroupId + this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + this.message.clearText() + this.replyTo.value = null + } + } + + fun reply(note: Note) { + replyTo.value = note + } + + fun clearReply() { + replyTo.value = null + } + + fun editFromDraft(draftMessage: String) { + message.setTextAndPlaceCursorAtEnd(draftMessage) + } + + fun canPost() = message.text.isNotBlank() + + fun onMessageChanged() { + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestions?.reset() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { + it.replaceCurrentWord(message, message.currentWord(), item) + it.reset() + } + } + + fun pickedMedia(media: ImmutableList) { + uploadState?.load(media) + } + + /** Sends the field's text. Mention rewriting and p-tagging happen in + * AccountViewModel.sendMarmotGroupMessage. Throws on send failure so + * the caller can surface the error. */ + suspend fun sendPost() { + val groupId = nostrGroupId ?: return + val text = message.text.toString().trim() + if (text.isEmpty()) return + + // Capture id+pubKey snapshot before suspending so a slow send + // doesn't race a user-cleared reply state. + val parentEvent = replyTo.value?.event + + accountViewModel.sendMarmotGroupMessage( + nostrGroupId = groupId, + text = text, + replyToInnerEventId = parentEvent?.id, + replyToInnerAuthorPubKey = parentEvent?.pubKey, + ) + + message.clearText() + replyTo.value = null + userSuggestions?.reset() + } +} From 908bc58190ac32f684171354ce078507d76e1732 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:25:57 +0000 Subject: [PATCH 6/6] test: lock in reorder-only semantics of mention priority ranking Extracts the priority sort into rankPriorityFirst() and covers: priority users move to the top, stable order within both groups, no injection of non-matching priority keys, and untouched list when priority is empty. --- .../userSuggestions/UserSuggestionState.kt | 27 ++++-- .../UserSuggestionPriorityRankingTest.kt | 84 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 22b4ef5515..76858b0776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -59,6 +59,22 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** + * Moves users whose pubkey is in [priority] to the top of [found], + * preserving the relative order everywhere else (stable sort). Reorders + * only — it never adds or removes entries, so priority keys whose users + * didn't match the search have no effect. + */ +fun rankPriorityFirst( + found: List, + priority: Set, +): List = + if (priority.isEmpty()) { + found + } else { + found.sortedByDescending { it.pubkeyHex in priority } + } + /** * Drives the @-mention autocomplete dropdown: searches the local cache, * relays, and NIP-05 identifiers for the word currently being typed. @@ -169,13 +185,10 @@ class UserSuggestionState( } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { - val found = account.cache.findUsersStartingWith(prefix, account) - val priority = priorityPubkeys() - if (priority.isEmpty()) { - found - } else { - found.sortedByDescending { it.pubkeyHex in priority } - } + rankPriorityFirst( + account.cache.findUsersStartingWith(prefix, account), + priorityPubkeys(), + ) } } else { emptyList() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt new file mode 100644 index 0000000000..fed8c9f63b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.rankPriorityFirst +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Locks in the @-mention priority semantics: priority pubkeys only move + * users that already matched the search to the top of the list — they + * never inject new entries, never remove any, and never disturb the + * search's relevance order within the priority / non-priority groups. + */ +class UserSuggestionPriorityRankingTest { + // User eagerly pins a few addressable note shells on construction; + // empty shells are enough since the ranking never reads them. + private val noContext = UserContext { addr -> AddressableNote(addr) } + + private fun user(hex: String) = User(hex, noContext) + + private val alice = user("aa".repeat(32)) + private val bob = user("bb".repeat(32)) + private val carol = user("cc".repeat(32)) + private val dave = user("dd".repeat(32)) + + @Test + fun emptyPriorityKeepsTheListUntouched() { + val found = listOf(alice, bob, carol) + + assertSame(found, rankPriorityFirst(found, emptySet())) + } + + @Test + fun priorityUsersMoveToTheTop() { + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex)) + + assertEquals(listOf(carol, alice, bob, dave), ranked) + } + + @Test + fun relativeOrderIsPreservedWithinBothGroups() { + // findUsersStartingWith returns relevance order; the stable sort + // must keep alice-before-carol (priority) and bob-before-dave (rest). + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(alice.pubkeyHex, carol.pubkeyHex)) + + assertEquals(listOf(alice, carol, bob, dave), ranked) + } + + @Test + fun priorityKeysThatDidNotMatchTheSearchAreNotInjected() { + val found = listOf(alice, bob) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex, dave.pubkeyHex)) + + assertEquals(found, ranked) + } +}