From 1d208edd33b8f91bf93b955815912f7d3776aecb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:09:38 +0000 Subject: [PATCH] feat: migrate ThinPaddingTextField to BasicTextField(TextFieldState) for GIF keyboard support Migrates all new post screens from the old BasicTextField(TextFieldValue) to the new BasicTextField(TextFieldState) API, which properly sets EditorInfo.contentMimeTypes to enable GIF keyboard input. Key changes: - Add TextFieldState-based ThinPaddingTextField overload (keep old for readonly dropdowns) - Create UrlUserTagOutputTransformation for the new OutputTransformation API - Create TextFieldState extension functions (insertUrlAtCursor, replaceCurrentWord, currentWord) - Update IMessageField interface to use TextFieldState + onMessageChanged() - Migrate all IMessageField implementors: ChannelNewMessageViewModel, ChatNewMessageViewModel, NewProductViewModel, NewPublicMessageViewModel, CommentPostViewModel, ShortNotePostViewModel, LongFormPostViewModel - Update PreviewState to accept String instead of TextFieldValue - Add TextFieldState overload to UserSuggestionState.replaceCurrentWord() - Update all post screen call sites https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ --- .../actions/UrlUserTagOutputTransformation.kt | 80 +++++++++++ .../ui/components/ThinPaddingTextField.kt | 127 ++++++++++++++++- .../creators/messagefield/IMessageField.kt | 6 +- .../creators/messagefield/MessageField.kt | 8 +- .../ui/note/creators/previews/PreviewState.kt | 9 +- .../userSuggestions/UserSuggestionState.kt | 14 ++ .../nip22Comments/CommentPostViewModel.kt | 51 +++---- .../nip22Comments/GenericCommentPostScreen.kt | 5 +- .../loggedIn/chats/privateDM/ChatroomView.kt | 5 +- .../privateDM/send/ChatNewMessageViewModel.kt | 94 ++++++------- .../chats/privateDM/send/NewGroupDMScreen.kt | 23 +-- .../send/PrivateMessageEditFieldRow.kt | 8 +- .../send/ChannelNewMessageViewModel.kt | 42 +++--- .../chats/publicChannels/send/EditFieldRow.kt | 8 +- .../nip23LongForm/LongFormPostViewModel.kt | 32 ++--- .../nip99Classifieds/NewProductScreen.kt | 5 +- .../nip99Classifieds/NewProductViewModel.kt | 93 ++++++------- .../discover/nip99Classifieds/SellProduct.kt | 34 ++--- .../loggedIn/home/ShortNotePostViewModel.kt | 65 ++++----- .../publicMessages/NewPublicMessageScreen.kt | 10 +- .../NewPublicMessageViewModel.kt | 84 ++++++----- .../compose/TextFieldStateExtensions.kt | 131 ++++++++++++++++++ 22 files changed, 636 insertions(+), 298 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt new file mode 100644 index 000000000..5728bcd6f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -0,0 +1,80 @@ +/* + * 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.actions + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import kotlin.coroutines.cancellation.CancellationException + +class UrlUserTagOutputTransformation( + val color: Color, +) : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + val text = asCharSequence().toString() + + // Find all @npub mentions using regex and replace in reverse order + // so that earlier indices remain valid after replacements. + val npubRegex = Regex("@npub1[a-z0-9]{58}") + val matches = npubRegex.findAll(text).toList().reversed() + + for (match in matches) { + try { + val key = decodePublicKey(match.value.removePrefix("@")) + val user = LocalCache.getOrCreateUser(key.toHexKey()) + val displayName = "@${user.toBestDisplayName()}" + + replace(match.range.first, match.range.last + 1, displayName) + + // Apply color styling to the replaced display name + addAnnotation( + SpanStyle(color = color, textDecoration = TextDecoration.None), + match.range.first, + match.range.first + displayName.length, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + + // Highlight URLs in remaining text + highlightUrls(color) + } +} + +private fun TextFieldBuffer.highlightUrls(color: Color) { + val text = asCharSequence().toString() + val urlPattern = android.util.Patterns.WEB_URL + + val matcher = urlPattern.matcher(text) + while (matcher.find()) { + addAnnotation( + SpanStyle(color = color, textDecoration = TextDecoration.None), + matcher.start(), + matcher.end(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt index e2a31c967..6931e34c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt @@ -27,6 +27,10 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalTextStyle @@ -35,8 +39,11 @@ import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor @@ -50,6 +57,124 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText // COPIED FROM TEXT FIELD // The only change is the contentPadding below +// New TextFieldState-based overload for GIF keyboard support +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThinPaddingTextField( + state: TextFieldState, + modifier: Modifier = Modifier, + onTextChanged: (() -> Unit)? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + inputTransformation: InputTransformation? = null, + outputTransformation: OutputTransformation? = null, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource? = null, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), + contentPadding: PaddingValues = + if (label == null) { + TextFieldDefaults.contentPaddingWithoutLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + } else { + TextFieldDefaults.contentPaddingWithLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + }, +) { + @Suppress("NAME_SHADOWING") + val interactionSource = interactionSource ?: remember { MutableInteractionSource() } + + if (onTextChanged != null) { + val callback by rememberUpdatedState(onTextChanged) + LaunchedEffect(state) { + snapshotFlow { state.text } + .collect { callback() } + } + } + + val textColor = + textStyle.color.takeOrElse { + val focused by interactionSource.collectIsFocusedAsState() + when { + !enabled -> MaterialTheme.colorScheme.placeholderText + isError -> MaterialTheme.colorScheme.onSurface + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface + } + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + val lineLimits = + if (singleLine) { + TextFieldLineLimits.SingleLine + } else { + TextFieldLineLimits.MultiLine(minLines, maxLines) + } + + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + BasicTextField( + state = state, + modifier = + modifier + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = 36.dp, + ), + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = keyboardOptions, + lineLimits = lineLimits, + interactionSource = interactionSource, + inputTransformation = inputTransformation, + outputTransformation = outputTransformation, + decorator = { innerTextField -> + TextFieldDefaults.DecorationBox( + value = state.text.toString(), + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = contentPadding, + ) + }, + ) + } +} + +// Legacy TextFieldValue-based overload (kept for readonly dropdown fields) @OptIn(ExperimentalMaterial3Api::class) @Composable fun ThinPaddingTextField( @@ -76,7 +201,6 @@ fun ThinPaddingTextField( interactionSource: MutableInteractionSource? = null, shape: Shape = TextFieldDefaults.shape, colors: TextFieldColors = TextFieldDefaults.colors(), - // new fields contentPadding: PaddingValues = if (label == null) { TextFieldDefaults.contentPaddingWithoutLabel( @@ -113,6 +237,7 @@ fun ThinPaddingTextField( val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + @Suppress("DEPRECATION") BasicTextField( value = value, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt index 2b2ea5063..b21c36c09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.messagefield -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.TextFieldState interface IMessageField { - var message: TextFieldValue + val message: TextFieldState - fun updateMessage(newMessage: TextFieldValue) + fun onMessageChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index 63509dca6..adf7bc682 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -65,8 +65,8 @@ fun MessageField( } ThinPaddingTextField( - value = viewModel.message, - onValueChange = viewModel::updateMessage, + state = viewModel.message, + onTextChanged = viewModel::onMessageChanged, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -91,7 +91,7 @@ fun MessageField( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt index 9a50d8bf8..09adafb3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.previews import androidx.compose.runtime.Stable -import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.service.CachedUrlParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -33,22 +32,22 @@ import kotlinx.coroutines.flow.map @Stable class PreviewState { - var source = MutableStateFlow(TextFieldValue("")) + var source = MutableStateFlow("") @OptIn(FlowPreview::class) val results = source .debounce(500) .map { - CachedUrlParser.parseValidUrls(it.text) + CachedUrlParser.parseValidUrls(it) }.distinctUntilChanged() .flowOn(Dispatchers.IO) fun reset() { - source.tryEmit(TextFieldValue("")) + source.tryEmit("") } - fun update(text: TextFieldValue) { + fun update(text: String) { source.tryEmit(text) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4adc385d5..b1da7a1ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -199,4 +200,17 @@ class UserSuggestionState( TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), ) } + + fun replaceCurrentWord( + state: TextFieldState, + word: String, + item: User, + ) { + val wordToInsert = "@${item.pubkeyNpub()}" + state.edit { + val lastWordStart = selection.end - word.length + replace(lastWordStart, selection.end, wordToInsert) + selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index da55dd466..d3765d2e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -147,7 +149,7 @@ open class CommentPostViewModel : var notifying by mutableStateOf?>(null) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -242,7 +244,7 @@ open class CommentPostViewModel : } open fun quote(quote: Note) { - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") quote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { @@ -262,7 +264,7 @@ open class CommentPostViewModel : wantsForwardZapTo = true } - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draft: Note) { @@ -322,11 +324,11 @@ open class CommentPostViewModel : wantsForwardZapTo = true } - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } suspend fun sendPostSync() { @@ -359,7 +361,7 @@ open class CommentPostViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val attachments = mutableSetOf() @@ -376,7 +378,7 @@ open class CommentPostViewModel : private suspend fun createTemplate(): EventTemplate? { val tagger = NewMessageTagger( - message = message.text, + message = message.text.toString(), dao = accountViewModel, ) tagger.run() @@ -525,8 +527,8 @@ open class CommentPostViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) + message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message.text.toString()) } } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = @@ -549,8 +551,8 @@ open class CommentPostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(state.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message.text.toString()) } } @@ -576,7 +578,7 @@ open class CommentPostViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") replyingTo = null externalIdentity = null @@ -618,9 +620,8 @@ open class CommentPostViewModel : notifying = notifying?.filter { it != userToRemove } } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(message) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { val lastWord = message.currentWord() @@ -651,8 +652,8 @@ open class CommentPostViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -668,8 +669,8 @@ open class CommentPostViewModel : open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -685,8 +686,8 @@ open class CommentPostViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -694,14 +695,14 @@ open class CommentPostViewModel : } fun canPost(): Boolean = - message.text.isNotBlank() && + message.text.toString().isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -718,7 +719,7 @@ open class CommentPostViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 3c866565f..c861233de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -46,7 +46,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel @@ -125,7 +125,8 @@ fun ReplyCommentPostScreen( postViewModel.quote(it) } message?.ifBlank { null }?.let { - postViewModel.updateMessage(TextFieldValue(it)) + postViewModel.message.setTextAndPlaceCursorAtEnd(it) + postViewModel.onMessageChanged() } attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 358247a94..047009a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -109,7 +109,8 @@ fun ChatroomView( if (draftMessage != null) { LaunchedEffect(key1 = draftMessage) { - newPostModel.updateMessage(TextFieldValue(draftMessage)) + newPostModel.message.setTextAndPlaceCursorAtEnd(draftMessage) + newPostModel.onMessageChanged() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 13130e854..49ff9068b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -204,7 +205,7 @@ class ChatNewMessageViewModel : var uploadsWaitingToBeSent by mutableStateOf>(emptyList()) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -216,8 +217,8 @@ class ChatNewMessageViewModel : var emojiSuggestions: EmojiSuggestionState? = null - var toUsers by mutableStateOf(TextFieldValue("")) - var subject by mutableStateOf(TextFieldValue("")) + val toUsers = TextFieldState() + val subject = TextFieldState() // Invoices var canAddInvoice by mutableStateOf(false) @@ -274,10 +275,9 @@ class ChatNewMessageViewModel : fun load(room: ChatroomKey) { this.room.tryEmit(room) - this.toUsers = - TextFieldValue( - room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + this.toUsers.setTextAndPlaceCursorAtEnd( + room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) } fun reply(replyNote: Note) { @@ -291,8 +291,8 @@ class ChatNewMessageViewModel : } fun quote(quote: Note) { - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") + urlPreviews.update(message.text.toString()) // creates a split with that author. val quotedAuthor = quote.author ?: return @@ -372,14 +372,13 @@ class ChatNewMessageViewModel : } draftEvent.subject()?.let { - subject = TextFieldValue() + subject.setTextAndPlaceCursorAtEnd("") } if (draftEvent is NIP17Group) { - toUsers = - TextFieldValue( - draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + toUsers.setTextAndPlaceCursorAtEnd( + draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) val replyId = when (draftEvent) { @@ -393,7 +392,7 @@ class ChatNewMessageViewModel : } } else if (draftEvent is PrivateDmEvent) { val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } - toUsers = TextFieldValue("@$recipientNPub") + toUsers.setTextAndPlaceCursorAtEnd("@$recipientNPub") val replyId = draftEvent.replyTo() if (replyId != null) { @@ -401,13 +400,14 @@ class ChatNewMessageViewModel : } } - message = + val draftText = if (draftEvent is PrivateDmEvent) { - TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "") + accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "" } else { - TextFieldValue(draftEvent.content) + draftEvent.content } - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(draftText) + urlPreviews.update(message.text.toString()) iMetaAttachments.addAll(draftEvent.imetas()) } @@ -422,7 +422,7 @@ class ChatNewMessageViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { account.deleteDraftIgnoreErrors(draftTag.current) } else { innerSendPost(draftTag.current) @@ -564,11 +564,12 @@ class ChatNewMessageViewModel : private suspend fun innerSendPost(draftTag: String?) { val room = room.value ?: return - val urls = findURLs(message.text) + val messageText = message.text.toString() + val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) + val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null - val message = message.text + val message = messageText val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null @@ -634,8 +635,8 @@ class ChatNewMessageViewModel : fun cancel() { draftTag.rotate() - message = TextFieldValue("") - subject = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") + subject.setTextAndPlaceCursorAtEnd("") replyTo.value = null @@ -668,15 +669,15 @@ class ChatNewMessageViewModel : } fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(newMessage) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -690,11 +691,9 @@ class ChatNewMessageViewModel : draftTag.newVersion() } - fun updateToUsers(newToUsersValue: TextFieldValue) { - toUsers = newToUsersValue - - if (newToUsersValue.selection.collapsed) { - val lastWord = newToUsersValue.currentWord() + fun onToUsersChanged() { + if (toUsers.selection.collapsed) { + val lastWord = toUsers.currentWord() userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS userSuggestions?.processCurrentWord(lastWord) } @@ -707,7 +706,7 @@ class ChatNewMessageViewModel : fun updateRoomFromUsersInput() { viewModelScope.launch(Dispatchers.IO) { delay(300) - val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel) + val toUsersTagger = NewMessageTagger(toUsers.text.toString(), null, null, accountViewModel) toUsersTagger.run() val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } @@ -721,8 +720,7 @@ class ChatNewMessageViewModel : } } - fun updateSubject(it: TextFieldValue) { - subject = it + fun onSubjectChanged() { draftTag.newVersion() } @@ -739,14 +737,14 @@ class ChatNewMessageViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { val lastWord = toUsers.currentWord() - toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + userSuggestions.replaceCurrentWord(toUsers, lastWord, item) updateRoomFromUsersInput() } @@ -760,8 +758,8 @@ class ChatNewMessageViewModel : fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -777,8 +775,8 @@ class ChatNewMessageViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -795,8 +793,8 @@ class ChatNewMessageViewModel : recipientsMissingDmRelays.value.isEmpty() fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) - urlPreviews.update(message) + message.insertUrlAtCursor(newElement) + urlPreviews.update(message.text.toString()) } override fun onCleared() { @@ -817,7 +815,7 @@ class ChatNewMessageViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 5d3a7ba1d..a40f4ad11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -67,7 +67,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -80,7 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -158,7 +158,8 @@ fun NewGroupDMScreen( LaunchedEffect(postViewModel, accountViewModel) { message?.ifBlank { null }?.let { - postViewModel.updateMessage(TextFieldValue(it)) + postViewModel.message.setTextAndPlaceCursorAtEnd(it) + postViewModel.onMessageChanged() } attachment?.let { withContext(Dispatchers.IO) { @@ -530,8 +531,8 @@ fun SendDirectMessageTo( ) ThinPaddingTextField( - value = postViewModel.toUsers, - onValueChange = postViewModel::updateToUsers, + state = postViewModel.toUsers, + onTextChanged = postViewModel::onToUsersChanged, modifier = Modifier .weight(1f) @@ -547,8 +548,8 @@ fun SendDirectMessageTo( color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = @@ -572,8 +573,8 @@ fun SendDirectMessageTo( ) ThinPaddingTextField( - value = postViewModel.subject, - onValueChange = { postViewModel.updateSubject(it) }, + state = postViewModel.subject, + onTextChanged = { postViewModel.onSubjectChanged() }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -581,8 +582,8 @@ fun SendDirectMessageTo( color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 9ef4170ff..22fe288db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -202,8 +202,8 @@ fun EditField( accountViewModel: AccountViewModel, ) { ThinPaddingTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, + state = channelScreenModel.message, + onTextChanged = { channelScreenModel.onMessageChanged() }, keyboardOptions = PostKeyboard, shape = EditFieldBorder, modifier = Modifier.fillMaxWidth(), @@ -232,7 +232,7 @@ fun EditField( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 5b293355b..885a8a59b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -137,7 +138,7 @@ open class ChannelNewMessageViewModel : val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) - var message by mutableStateOf(TextFieldValue("")) + val message = TextFieldState() var urlPreview by mutableStateOf(null) val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false @@ -274,7 +275,7 @@ open class ChannelNewMessageViewModel : } } - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) @@ -302,7 +303,7 @@ open class ChannelNewMessageViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { account.deleteDraftIgnoreErrors(draftTag.current) } else { val attachments = mutableSetOf() @@ -365,14 +366,14 @@ open class ChannelNewMessageViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor(it.toNostrUri()) + message.insertUrlAtCursor(it.toNostrUri()) } urlPreview = findUrlInMessage() } else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) { iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason) - message = message.insertUrlAtCursor(upload.result.url) + message.insertUrlAtCursor(upload.result.url) urlPreview = findUrlInMessage() } } @@ -392,18 +393,19 @@ open class ChannelNewMessageViewModel : private suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null + val messageText = message.text.toString() val tagger = NewMessageTagger( - message = message.text, + message = messageText, pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), dao = accountViewModel, ) tagger.run() - val urls = findURLs(message.text) + val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) + val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) val channelRelays = channel.relays() val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null @@ -533,7 +535,7 @@ open class ChannelNewMessageViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") replyTo.value = null @@ -561,18 +563,18 @@ open class ChannelNewMessageViewModel : emojiSuggestions?.reset() } - open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text).withScheme.firstOrNull() + open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text.toString()).withScheme.firstOrNull() open fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - open fun updateMessage(newMessage: TextFieldValue) { - message = newMessage + open fun onMessageChanged() { urlPreview = findUrlInMessage() - if (newMessage.selection.collapsed) { - val lastWord = newMessage.currentWord() + if (message.selection.collapsed) { + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -600,7 +602,7 @@ open class ChannelNewMessageViewModel : userSuggestions?.let { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = it.replaceCurrentWord(message, lastWord, item) + it.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.addItem(item) forwardZapToEditting = TextFieldValue("") @@ -615,7 +617,7 @@ open class ChannelNewMessageViewModel : open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() @@ -631,7 +633,7 @@ open class ChannelNewMessageViewModel : } } - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() @@ -648,7 +650,7 @@ open class ChannelNewMessageViewModel : uploadState?.multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } override fun locationManager(): LocationState = Amethyst.instance.locationManager @@ -675,7 +677,7 @@ open class ChannelNewMessageViewModel : fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index ddf1ad2d4..1c2669b35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -114,8 +114,8 @@ fun EditFieldRow( } ThinPaddingTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, + state = channelScreenModel.message, + onTextChanged = { channelScreenModel.onMessageChanged() }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -151,7 +151,7 @@ fun EditFieldRow( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index ddbcdc910..a4ecc0611 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm import android.R.attr.version import android.content.Context import androidx.compose.runtime.Stable +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -148,7 +150,7 @@ class LongFormPostViewModel : var isUploadingCoverImage by mutableStateOf(false) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() var showPreview by mutableStateOf(false) @@ -242,7 +244,7 @@ class LongFormPostViewModel : summary = TextFieldValue(noteEvent.summary() ?: "") publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt coverImageUrl = noteEvent.image() ?: "" - message = TextFieldValue(noteEvent.content) + message.setTextAndPlaceCursorAtEnd(noteEvent.content) existingDTag = noteEvent.dTag() } @@ -266,7 +268,7 @@ class LongFormPostViewModel : summary = TextFieldValue(draftEvent.summary() ?: "") publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt coverImageUrl = draftEvent.image() ?: "" - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) existingDTag = draftEvent.dTag() canAddInvoice = accountViewModel.userProfile().lnAddress() != null @@ -336,7 +338,7 @@ class LongFormPostViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank() && title.text.isBlank()) { + if (message.text.toString().isBlank() && title.text.isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val template = createTemplate() ?: return @@ -350,7 +352,7 @@ class LongFormPostViewModel : val tagger = NewMessageTagger( - message.text, + message.text.toString(), null, null, accountViewModel, @@ -540,7 +542,7 @@ class LongFormPostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) val markdownImage = "![${alt ?: ""}](${state.result.url})" - message = message.insertUrlAtCursor(markdownImage) + message.insertUrlAtCursor(markdownImage) } } @@ -560,7 +562,7 @@ class LongFormPostViewModel : title = TextFieldValue("") summary = TextFieldValue("") coverImageUrl = "" - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") publishedAt = TimeUtils.now() showPreview = false @@ -595,11 +597,9 @@ class LongFormPostViewModel : this.multiOrchestrator?.remove(selected) } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - + override fun onMessageChanged() { if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -627,7 +627,7 @@ class LongFormPostViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) + userSuggestions.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -642,7 +642,7 @@ class LongFormPostViewModel : fun autocompleteWithEmoji(item: EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() draftTag.newVersion() } @@ -656,21 +656,21 @@ class LongFormPostViewModel : } } - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() draftTag.newVersion() } fun canPost(): Boolean = title.text.isNotBlank() && - message.text.isNotBlank() && + message.text.toString().isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index ef9287527..b1da22259 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel @@ -112,7 +112,8 @@ fun NewProductScreen( postViewModel.quote(it) } message?.ifBlank { null }?.let { - postViewModel.updateMessage(TextFieldValue(it)) + postViewModel.message.setTextAndPlaceCursorAtEnd(it) + postViewModel.onMessageChanged() } attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 826fa4eaa..ba9dbb4a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -131,7 +132,7 @@ open class NewProductViewModel : var productImages by mutableStateOf>(emptyList()) val iMetaDescription = IMetaAttachments() - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -151,10 +152,10 @@ open class NewProductViewModel : val strippingFailureConfirmation = SuspendableConfirmation() // Classifieds - var title by mutableStateOf(TextFieldValue("")) - var price by mutableStateOf(TextFieldValue("")) - var locationText by mutableStateOf(TextFieldValue("")) - var category by mutableStateOf(TextFieldValue("")) + val title = TextFieldState() + val price = TextFieldState() + val locationText = TextFieldState() + val category = TextFieldState() var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) // Invoices @@ -226,7 +227,7 @@ open class NewProductViewModel : open fun quote(quote: Note) { val accountViewModel = accountViewModel - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") quote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { @@ -246,7 +247,7 @@ open class NewProductViewModel : wantsForwardZapTo = true } - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draft: Note) { @@ -284,10 +285,10 @@ open class NewProductViewModel : zapRaiserAmount.value = zapraiser } - title = TextFieldValue(draftEvent.title() ?: "") - price = TextFieldValue(draftEvent.price()?.amount ?: "") - category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "") - locationText = TextFieldValue(draftEvent.location() ?: "") + title.setTextAndPlaceCursorAtEnd(draftEvent.title() ?: "") + price.setTextAndPlaceCursorAtEnd(draftEvent.price()?.amount ?: "") + category.setTextAndPlaceCursorAtEnd(draftEvent.categories().firstOrNull() ?: "") + locationText.setTextAndPlaceCursorAtEnd(draftEvent.location() ?: "") condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW val imageSet = draftEvent.images().toMutableSet() @@ -305,9 +306,9 @@ open class NewProductViewModel : productImages = productImages + ProductImageMeta(it) } - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } suspend fun sendPostSync() { @@ -323,7 +324,7 @@ open class NewProductViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val template = createTemplate() ?: return @@ -336,7 +337,7 @@ open class NewProductViewModel : val tagger = NewMessageTagger( - message = message.text, + message = message.text.toString(), dao = accountViewModel, ) tagger.run() @@ -356,15 +357,15 @@ open class NewProductViewModel : val template = ClassifiedsEvent.build( - title.text, - PriceTag(price.text, "SATS", null), + title.text.toString(), + PriceTag(price.text.toString(), "SATS", null), tagger.message, - locationText.text.ifBlank { null }, + locationText.text.toString().ifBlank { null }, condition, ) { productImages.forEach { image(it.url) } - hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message)) + hashtags(listOfNotNull(category.text.toString().ifBlank { null }) + findHashtags(tagger.message)) quotes(quotes) geoHash?.let { geohash(it) } @@ -438,8 +439,8 @@ open class NewProductViewModel : } else { iMetaDescription.add(it.result, alt, contentWarningReason) - message = message.insertUrlAtCursor(it.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(it.result.url) + urlPreviews.update(message.text.toString()) } } } @@ -458,7 +459,7 @@ open class NewProductViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") multiOrchestrator = null mediaUploadTracker.finishUpload() @@ -468,10 +469,10 @@ open class NewProductViewModel : zapRaiserAmount.value = null condition = ConditionTag.CONDITION.USED_LIKE_NEW - locationText = TextFieldValue("") - title = TextFieldValue("") - category = TextFieldValue("") - price = TextFieldValue("") + locationText.setTextAndPlaceCursorAtEnd("") + title.setTextAndPlaceCursorAtEnd("") + category.setTextAndPlaceCursorAtEnd("") + price.setTextAndPlaceCursorAtEnd("") wantsForwardZapTo = false wantsToMarkAsSensitive = false @@ -505,9 +506,8 @@ open class NewProductViewModel : this.multiOrchestrator?.remove(selected) } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(message) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { val lastWord = message.currentWord() @@ -538,8 +538,8 @@ open class NewProductViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -555,8 +555,8 @@ open class NewProductViewModel : open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -572,8 +572,8 @@ open class NewProductViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -591,7 +591,7 @@ open class NewProductViewModel : multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -620,7 +620,7 @@ open class NewProductViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { @@ -648,19 +648,11 @@ open class NewProductViewModel : draftTag.newVersion() } - fun updateTitle(it: TextFieldValue) { - title = it + fun onTitleChanged() { draftTag.newVersion() } - fun updatePrice(it: TextFieldValue) { - runCatching { - if (it.text.isEmpty()) { - price = TextFieldValue("") - } else if (it.text.toLongOrNull() != null) { - price = it - } - } + fun onPriceChanged() { draftTag.newVersion() } @@ -669,13 +661,12 @@ open class NewProductViewModel : draftTag.newVersion() } - fun updateCategory(value: TextFieldValue) { - category = value + fun updateCategory(text: String) { + category.setTextAndPlaceCursorAtEnd(text) draftTag.newVersion() } - fun updateLocation(it: TextFieldValue) { - locationText = it + fun onLocationChanged() { draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index 590a3872f..9cb2e4aec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -44,12 +44,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TextFieldValue // kept for readonly dropdown ThinPaddingTextField import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.TitleExplainer @@ -109,10 +109,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { ) ThinPaddingTextField( - value = postViewModel.title, - onValueChange = { - postViewModel.updateTitle(it) - }, + state = postViewModel.title, + onTextChanged = { postViewModel.onTitleChanged() }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -120,8 +118,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = @@ -146,10 +144,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( modifier = Modifier.fillMaxWidth(), - value = postViewModel.price, - onValueChange = { - postViewModel.updatePrice(it) - }, + state = postViewModel.price, + onTextChanged = { postViewModel.onPriceChanged() }, placeholder = { Text( text = "1000", @@ -275,10 +271,10 @@ fun SellProduct(postViewModel: NewProductViewModel) { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() } TextSpinner( - placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "", + placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text.toString() }?.second ?: "", options = categoryOptions, onSelect = { - postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second)) + postViewModel.updateCategory(categoryTypes[it].second) }, modifier = Modifier @@ -313,10 +309,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { ) ThinPaddingTextField( - value = postViewModel.locationText, - onValueChange = { - postViewModel.updateLocation(it) - }, + state = postViewModel.locationText, + onTextChanged = { postViewModel.onLocationChanged() }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -324,8 +318,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 11601d54c..83176c5c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -28,7 +28,8 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -183,7 +184,7 @@ open class ShortNotePostViewModel : val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -367,7 +368,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null quote?.let { quotedNote -> - message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}") + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}") quotedNote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != user.pubkeyHex) { @@ -385,7 +386,7 @@ open class ShortNotePostViewModel : } fork?.let { forkedNoted -> - message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "") + message.setTextAndPlaceCursorAtEnd(version?.event?.content ?: forkedNoted.event?.content ?: "") forkedNoted.event?.isSensitiveOrNSFW()?.let { if (it) wantsToMarkAsSensitive = true @@ -436,7 +437,7 @@ open class ShortNotePostViewModel : } } - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draft: Note) { @@ -533,11 +534,11 @@ open class ShortNotePostViewModel : wantsPoll = false wantsZapPoll = false - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draftEvent: PollEvent) { @@ -605,11 +606,11 @@ open class ShortNotePostViewModel : pollType = draftEvent.pollType() closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draftEvent: ZapPollEvent) { @@ -679,11 +680,11 @@ open class ShortNotePostViewModel : zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() } zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead() - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } suspend fun sendPostSync() { @@ -750,7 +751,7 @@ open class ShortNotePostViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val attachments = mutableSetOf() @@ -811,7 +812,7 @@ open class ShortNotePostViewModel : val tagger = NewMessageTagger( - message.text, + message.text.toString(), pTags, eTags, accountViewModel, @@ -1000,8 +1001,8 @@ open class ShortNotePostViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) + message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message.text.toString()) } } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = @@ -1024,8 +1025,8 @@ open class ShortNotePostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(state.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message.text.toString()) } } @@ -1042,7 +1043,7 @@ open class ShortNotePostViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") forkedFromNote = null @@ -1104,15 +1105,15 @@ open class ShortNotePostViewModel : } open fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(message) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -1140,8 +1141,8 @@ open class ShortNotePostViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -1157,8 +1158,8 @@ open class ShortNotePostViewModel : open fun autocompleteWithEmoji(item: EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -1174,8 +1175,8 @@ open class ShortNotePostViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -1201,7 +1202,7 @@ open class ShortNotePostViewModel : } // Regular text/media posts require text - return message.text.isNotBlank() && + return message.text.toString().isNotBlank() && !mediaUploadTracker.isUploading && !isUploadingVoice && !wantsInvoice && @@ -1226,7 +1227,7 @@ open class ShortNotePostViewModel : } fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -1378,7 +1379,7 @@ open class ShortNotePostViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index f6ab68d8a..ada38e5e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -415,8 +415,8 @@ fun SendDirectMessageTo( ) ThinPaddingTextField( - value = postViewModel.toUsers, - onValueChange = postViewModel::updateToUsers, + state = postViewModel.toUsers, + onTextChanged = postViewModel::onToUsersChanged, modifier = Modifier .weight(1f) @@ -432,8 +432,8 @@ fun SendDirectMessageTo( color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 28daa1bf2..fdc74178e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -147,7 +148,7 @@ class NewPublicMessageViewModel : val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -160,7 +161,7 @@ class NewPublicMessageViewModel : var emojiSuggestions: EmojiSuggestionState? = null - var toUsers by mutableStateOf(TextFieldValue("")) + val toUsers = TextFieldState() // Images and Videos var multiOrchestrator by mutableStateOf(null) @@ -218,10 +219,9 @@ class NewPublicMessageViewModel : fun load(users: Set) { val userSet = users - account.userProfile().pubkeyHex - toUsers = - TextFieldValue( - userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + toUsers.setTextAndPlaceCursorAtEnd( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) } fun reply(post: Note) { @@ -229,8 +229,8 @@ class NewPublicMessageViewModel : } fun quote(quote: Note) { - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") + urlPreviews.update(message.text.toString()) // creates a split with that author. val quotedAuthor = quote.author ?: return @@ -306,13 +306,12 @@ class NewPublicMessageViewModel : val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex - toUsers = - TextFieldValue( - userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + toUsers.setTextAndPlaceCursorAtEnd( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) - message = TextFieldValue(draftEvent.content) - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) + urlPreviews.update(message.text.toString()) iMetaAttachments.addAll(draftEvent.imetas()) } @@ -349,7 +348,7 @@ class NewPublicMessageViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val broadcast = mutableSetOf() @@ -364,13 +363,14 @@ class NewPublicMessageViewModel : } private suspend fun createTemplate(): EventTemplate { - val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel) + val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text.toString(), null, null, accountViewModel) toUsersTagger.run() + val messageText = message.text.toString() val msg = replyingTo?.let { - it.toNostrUri() + "\n" + message.text - } ?: message.text + it.toNostrUri() + "\n" + messageText + } ?: messageText val tagger = NewMessageTagger(msg, null, null, accountViewModel) tagger.run() @@ -470,8 +470,8 @@ class NewPublicMessageViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) + message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message.text.toString()) } } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = @@ -494,8 +494,8 @@ class NewPublicMessageViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(state.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message.text.toString()) } } @@ -512,8 +512,8 @@ class NewPublicMessageViewModel : fun cancel() { draftTag.rotate() - toUsers = TextFieldValue("") - message = TextFieldValue("") + toUsers.setTextAndPlaceCursorAtEnd("") + message.setTextAndPlaceCursorAtEnd("") multiOrchestrator = null wantsInvoice = false @@ -546,15 +546,15 @@ class NewPublicMessageViewModel : } fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(newMessage) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -569,11 +569,9 @@ class NewPublicMessageViewModel : draftTag.newVersion() } - fun updateToUsers(newToUsersValue: TextFieldValue) { - toUsers = newToUsersValue - - if (newToUsersValue.selection.collapsed) { - val lastWord = newToUsersValue.currentWord() + fun onToUsersChanged() { + if (toUsers.selection.collapsed) { + val lastWord = toUsers.currentWord() userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS userSuggestions?.processCurrentWord(lastWord) } @@ -594,14 +592,14 @@ class NewPublicMessageViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { val lastWord = toUsers.currentWord() - toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + userSuggestions.replaceCurrentWord(toUsers, lastWord, item) } userSuggestionsMainMessage = null @@ -614,8 +612,8 @@ class NewPublicMessageViewModel : fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -631,8 +629,8 @@ class NewPublicMessageViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -648,7 +646,7 @@ class NewPublicMessageViewModel : multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -671,7 +669,7 @@ class NewPublicMessageViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt new file mode 100644 index 000000000..6b74453af --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt @@ -0,0 +1,131 @@ +/* + * 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.commons.compose + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.text.TextRange +import kotlin.math.max +import kotlin.math.min + +fun TextFieldState.insertUrlAtCursor(url: String) { + edit { + var toInsert = url.trim() + val selStart = selection.start + val selEnd = selection.end + val text = asCharSequence() + + if (selStart > 0 && text[selStart - 1] != ' ' && text[selStart - 1] != '\n') { + toInsert = " $toInsert" + } + + // takes the position before adding an empty char after the url + val endOfUrlIndex = selStart + toInsert.length + + if (selEnd < length && text[selEnd] != ' ' && text[selEnd] != '\n') { + toInsert = "$toInsert " + } + + replace(selStart, selEnd, toInsert) + selection = TextRange(endOfUrlIndex, endOfUrlIndex) + } +} + +fun TextFieldState.replaceCurrentWord(wordToInsert: String) { + edit { + val text = asCharSequence() + val selStart = selection.start + val selEnd = selection.end + val lastWordStart = currentWordStartIdx(text, selStart) + val lastWordEnd = currentWordEndIdx(text, selEnd, length) + val cursor = lastWordStart + wordToInsert.length + replace(lastWordStart, lastWordEnd, wordToInsert) + selection = TextRange(cursor, cursor) + } +} + +fun TextFieldState.currentWord(): String { + val text = this.text + val selection = this.selection + if (selection.end != selection.start) return "" + + val start = currentWordStartIdx(text, selection.start) + val end = currentWordEndIdx(text, selection.end, text.length) + + return if (start < end) { + text.subSequence(start, end).toString() + } else { + "" + } +} + +private fun currentWordStartIdx( + text: CharSequence, + selectionStart: Int, +): Int { + val previousNewLine = text.lastIndexOf('\n', selectionStart - 1) + val previousSpace = text.lastIndexOf(' ', selectionStart - 1) + + return max( + previousNewLine, + previousSpace, + ) + 1 +} + +private fun currentWordEndIdx( + text: CharSequence, + selectionEnd: Int, + textLength: Int, +): Int { + val nextNewLine = text.indexOf('\n', selectionEnd) + val nextSpace = text.indexOf(' ', selectionEnd) + + if (nextSpace < 0 && nextNewLine < 0) return selectionEnd + if (nextSpace > 0 && nextNewLine > 0) { + return min( + nextNewLine, + nextSpace, + ) + } + if (nextSpace > 0) { + return nextSpace + } + return nextNewLine +} + +private fun CharSequence.lastIndexOf( + char: Char, + startIndex: Int, +): Int { + for (i in startIndex downTo 0) { + if (this[i] == char) return i + } + return -1 +} + +private fun CharSequence.indexOf( + char: Char, + startIndex: Int, +): Int { + for (i in startIndex until length) { + if (this[i] == char) return i + } + return -1 +}