mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #3171 from vitorpamplona/claude/intelligent-newton-8qgvo8
Extract Marmot group message composer to ViewModel
This commit is contained in:
+32
-1
@@ -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,
|
||||
|
||||
+31
-1
@@ -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()
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
+60
-72
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -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()
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user