Merge pull request #3171 from vitorpamplona/claude/intelligent-newton-8qgvo8

Extract Marmot group message composer to ViewModel
This commit is contained in:
Vitor Pamplona
2026-06-10 14:35:50 -04:00
committed by GitHub
11 changed files with 398 additions and 78 deletions
@@ -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,
@@ -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,36 @@ 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<User>,
priority: Set<HexKey>,
): List<User> =
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.
*
* [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<HexKey> = { emptySet() },
) {
val invalidations = MutableStateFlow(0)
val currentWord = MutableStateFlow("")
@@ -158,7 +185,10 @@ class UserSuggestionState(
}
if (prefix != null) {
logTime("UserSuggestionState Search $prefix version $version") {
account.cache.findUsersStartingWith(prefix, account)
rankPriorityFirst(
account.cache.findUsersStartingWith(prefix, account),
priorityPubkeys(),
)
}
} else {
emptyList()
@@ -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
@@ -1627,6 +1628,11 @@ class AccountViewModel(
replyToInnerEventId: HexKey? = null,
replyToInnerAuthorPubKey: HexKey? = null,
) {
// 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.
@@ -1634,10 +1640,11 @@ class AccountViewModel(
account.marmotManager
?.buildTextMessage(
nostrGroupId = nostrGroupId,
text = text,
text = tagger.message,
replyToEventId = replyToInnerEventId,
replyToAuthorPubKey = replyToInnerAuthorPubKey,
persistOwn = false,
mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(),
)
?: return
val relays = account.marmotGroupRelays(nostrGroupId)
@@ -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,16 +46,19 @@ 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.MentionPreservingInputTransformation
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.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
@@ -69,6 +68,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
@@ -96,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<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.
@@ -116,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)
}
}
@@ -139,7 +135,7 @@ fun MarmotGroupChatView(
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "MarmotGroup/$nostrGroupId",
onWantsToReply = { note -> replyTo.value = note },
onWantsToReply = { note -> newMessageModel.reply(note) },
onWantsToEditDraft = { },
)
}
@@ -148,8 +144,7 @@ fun MarmotGroupChatView(
MarmotGroupMessageComposer(
nostrGroupId = nostrGroupId,
messageState = messageState,
replyTo = replyTo,
newMessageModel = newMessageModel,
accountViewModel = accountViewModel,
nav = nav,
onMessageSent = {
@@ -162,49 +157,59 @@ fun MarmotGroupChatView(
@Composable
fun MarmotGroupMessageComposer(
nostrGroupId: HexKey,
messageState: TextFieldState,
replyTo: MutableState<Note?>,
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,
)
}
// Upload dialog
uploadState.multiOrchestrator?.let {
MarmotGroupFileUploadDialog(
nostrGroupId = nostrGroupId,
state = uploadState,
accountViewModel = accountViewModel,
nav = nav,
onUpload = { onMessageSent() },
onCancel = uploadState::reset,
)
DisposableEffect(nostrGroupId) {
onDispose { newMessageModel.userSuggestions?.reset() }
}
replyTo.value?.let {
// Upload dialog
newMessageModel.uploadState?.let { uploadState ->
uploadState.multiOrchestrator?.let {
MarmotGroupFileUploadDialog(
nostrGroupId = nostrGroupId,
state = uploadState,
accountViewModel = accountViewModel,
nav = nav,
onUpload = { onMessageSent() },
onCancel = uploadState::reset,
)
}
}
newMessageModel.replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
replyTo.value = null
newMessageModel.clearReply()
}
}
Column(modifier = EditFieldModifier) {
newMessageModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
newMessageModel::autocompleteWithUser,
accountViewModel,
SuggestionListDefaultHeightChat,
)
}
ThinPaddingTextField(
state = messageState,
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),
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
@@ -216,9 +221,7 @@ fun MarmotGroupMessageComposer(
leadingIcon = {
MarmotGalleryLeadingIcon(
isUploading = isUploading,
onImageChosen = { selectedMedia ->
uploadState.load(selectedMedia)
},
onImageChosen = newMessageModel::pickedMedia,
)
},
trailingIcon = {
@@ -226,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()
}
}
}
@@ -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<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(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<SelectedMedia>) {
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()
}
}
@@ -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)
@@ -188,7 +188,16 @@ open class ChannelNewMessageViewModel :
this.canAddZapRaiser = hasLnAddress()
this.userSuggestions?.reset()
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.userSuggestions =
UserSuggestionState(
accountVM.account,
accountVM.nip05ClientBuilder(),
priorityPubkeys = {
// 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()
},
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
@@ -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().toSet() + space.pubKey
} ?: emptySet()
},
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
+1
View File
@@ -214,6 +214,7 @@
<string name="voice_anonymize_description">Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners.</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string>
<string name="reply_here">"reply here… "</string>
<string name="user_suggestion_in_this_chat">In this chat</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copies the Note ID to the clipboard for sharing in Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Copy Channel ID (Note) to the Clipboard</string>
<string name="edits_the_channel_metadata">Edits the Channel Metadata</string>
@@ -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)
}
}
@@ -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