mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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.
This commit is contained in:
+49
-105
@@ -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<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.
|
||||
@@ -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<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,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user