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