From fde5818044c41fd25bf5ebd76f1b3aa72459b27d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:43:30 +0000 Subject: [PATCH] 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() + } +}