From 1d208edd33b8f91bf93b955815912f7d3776aecb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:09:38 +0000 Subject: [PATCH 1/5] 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 0000000000..5728bcd6f8 --- /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 e2a31c967b..6931e34c6f 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 2b2ea50636..b21c36c096 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 63509dca64..adf7bc6822 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 9a50d8bf82..09adafb3f7 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 4adc385d54..b1da7a1bad 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 da55dd4665..d3765d2e17 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 3c866565f7..c861233dee 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 358247a945..047009a1a9 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 13130e854f..49ff9068b8 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 5d3a7ba1d0..a40f4ad119 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 9ef4170ffc..22fe288db0 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 5b293355b6..885a8a59b1 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 ddf1ad2d4d..1c2669b357 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 ddbcdc910f..a4ecc0611f 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 ef92875275..b1da22259b 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 826fa4eaa4..ba9dbb4a79 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 590a3872f8..9cb2e4aec5 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 11601d54c1..83176c5c4f 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 f6ab68d8a0..ada38e5e57 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 28daa1bf2a..fdc74178e9 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 0000000000..6b74453af1 --- /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 +} From f997586e4b40e8cc754c8896b37194c8a78c4056 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:11:09 +0000 Subject: [PATCH 2/5] feat: migrate remaining IMessageField implementors to TextFieldState Completes the TextFieldState migration for ShortNotePostViewModel, LongFormPostViewModel, and their associated post screens (GeoHash, Hashtag, ShortNote, LongForm). https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ --- .../discover/nip23LongForm/LongFormPostScreen.kt | 13 +++++++------ .../discover/nip23LongForm/LongFormPostViewModel.kt | 2 +- .../ui/screen/loggedIn/geohash/GeoHashPostScreen.kt | 5 +++-- .../ui/screen/loggedIn/hashtag/HashtagPostScreen.kt | 5 +++-- .../ui/screen/loggedIn/home/ShortNotePostScreen.kt | 5 +++-- .../screen/loggedIn/home/ShortNotePostViewModel.kt | 1 + 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 664331698b..925d818d96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -62,8 +62,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList 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.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -293,7 +294,7 @@ private fun MarkdownPostScreenBody( .padding(Size10dp), ) { RenderContentAsMarkdown( - content = postViewModel.message.text, + content = postViewModel.message.text.toString(), tags = EmptyTagList, canPreview = true, quotesLeft = 1, @@ -304,9 +305,9 @@ private fun MarkdownPostScreenBody( } } else { // Markdown editor - OutlinedTextField( - value = postViewModel.message, - onValueChange = postViewModel::updateMessage, + ThinPaddingTextField( + state = postViewModel.message, + onTextChanged = postViewModel::onMessageChanged, modifier = Modifier .fillMaxWidth() @@ -328,7 +329,7 @@ private fun MarkdownPostScreenBody( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, 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 a4ecc0611f..7b4a823911 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 @@ -700,7 +700,7 @@ class LongFormPostViewModel : 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/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index 7eb071ecc1..f04ac3543d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -67,7 +67,8 @@ fun GeoHashPostScreen( 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/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index 46aad80cf4..d66c7ec605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -67,7 +67,8 @@ fun HashtagPostScreen( 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/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index b0c5890587..c088d42c8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -55,7 +55,7 @@ import androidx.compose.runtime.collectAsState 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -150,7 +150,8 @@ fun ShortNotePostScreen( val draft = draftId?.let { accountViewModel.getNoteIfExists(it) } postViewModel.load(baseReplyTo, quote, fork, version, draft) 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/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 83176c5c4f..f117f1124c 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 @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst From 72bc7e0ed5fb36c570d822d05f3854d86a2c04ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:13:18 +0000 Subject: [PATCH 3/5] style: apply linter formatting to TextFieldState migration https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ --- .../amethyst/ui/note/nip22Comments/CommentPostViewModel.kt | 2 +- .../ui/note/nip22Comments/GenericCommentPostScreen.kt | 2 +- .../ui/screen/loggedIn/chats/privateDM/ChatroomView.kt | 2 +- .../loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt | 2 +- .../screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt | 2 +- .../chats/publicChannels/send/ChannelNewMessageViewModel.kt | 2 +- .../loggedIn/discover/nip23LongForm/LongFormPostScreen.kt | 2 +- .../loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt | 2 +- .../loggedIn/discover/nip99Classifieds/NewProductScreen.kt | 2 +- .../loggedIn/discover/nip99Classifieds/NewProductViewModel.kt | 2 +- .../screen/loggedIn/discover/nip99Classifieds/SellProduct.kt | 2 +- .../amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt | 2 +- .../amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt | 2 +- .../amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt | 2 +- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 4 ++-- .../notifications/publicMessages/NewPublicMessageViewModel.kt | 2 +- 16 files changed, 17 insertions(+), 17 deletions(-) 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 d3765d2e17..a488bde7a7 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 @@ -24,11 +24,11 @@ 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 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 c861233dee..e8d4bb98f9 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 @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -46,7 +47,6 @@ 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.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel 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 047009a1a9..10c960c87d 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 @@ -24,12 +24,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -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 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 49ff9068b8..818fdfcd47 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 @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable 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 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 a40f4ad119..a7a84109ad 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 @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddPhotoAlternate @@ -67,7 +68,6 @@ 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.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 885a8a59b1..4be84c8be4 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 @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 925d818d96..7eb346617d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -64,11 +64,11 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar 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 7b4a823911..127c2073fe 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 @@ -22,9 +22,9 @@ 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.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf 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 b1da22259b..e0c13365e3 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 @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -41,7 +42,6 @@ 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.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel 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 ba9dbb4a79..ee4dd6c2f2 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 @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable 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 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 9cb2e4aec5..c13346b81e 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,7 +44,7 @@ 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 // kept for readonly dropdown ThinPaddingTextField +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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index f04ac3543d..def8f5dc0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index d66c7ec605..962b13e452 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index c088d42c8a..9a3e27ee6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Poll @@ -55,7 +56,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri 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 f117f1124c..9d7e7fd93e 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 @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home 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.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -28,8 +30,6 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 fdc74178e9..9fc9d0fccb 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 @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable 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 From 4d8583eed9a570958da11a1d471a2c0d65e7ab8e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 03:43:51 +0000 Subject: [PATCH 4/5] feat: replace hex input with user search in relay manager allow/ban dialogs Users can now search by name, npub, or NIP-05 identifier when adding users to relay allow/ban lists, using the same UserSuggestionState mechanism as the My List screens. The hex pubkey input was not user-friendly since most users don't know their hex representation. https://claude.ai/code/session_01SWReoziLKJKxrbwDWprQYs --- .../relays/nip86/RelayManagementScreen.kt | 215 +++++++++++++++++- 1 file changed, 207 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index 5c7827b586..5c6346fdc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86 +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -29,10 +31,13 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Block @@ -67,28 +72,39 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.AboutDisplay +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -96,6 +112,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun RelayManagementScreen( @@ -398,26 +416,26 @@ private fun PubkeysTab( } if (showBanDialog) { - HexInputDialog( + UserSearchDialog( title = stringResource(R.string.relay_management_ban_pubkey), - label = stringResource(R.string.relay_management_pubkey_hex), - onConfirm = { hex, reason -> - viewModel.banPubkey(hex, reason.ifBlank { null }) + onConfirm = { user, reason -> + viewModel.banPubkey(user.pubkeyHex, reason.ifBlank { null }) showBanDialog = false }, onDismiss = { showBanDialog = false }, + accountViewModel = accountViewModel, ) } if (showAllowDialog) { - HexInputDialog( + UserSearchDialog( title = stringResource(R.string.relay_management_allow_pubkey), - label = stringResource(R.string.relay_management_pubkey_hex), - onConfirm = { hex, reason -> - viewModel.allowPubkey(hex, reason.ifBlank { null }) + onConfirm = { user, reason -> + viewModel.allowPubkey(user.pubkeyHex, reason.ifBlank { null }) showAllowDialog = false }, onDismiss = { showAllowDialog = false }, + accountViewModel = accountViewModel, ) } } @@ -973,6 +991,187 @@ private fun SettingsField( } } +@Composable +private fun UserSearchDialog( + title: String, + onConfirm: (User, String) -> Unit, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + var selectedUser by remember { mutableStateOf(null) } + var reasonValue by remember { mutableStateOf("") } + + UserSearchDataSourceSubscription(userSuggestions, accountViewModel) + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + userSuggestions.invalidateData() + } + } + launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { + userSuggestions.invalidateData() + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + val selected = selectedUser + if (selected != null) { + SelectedUserRow(selected, accountViewModel) { + selectedUser = null + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = reasonValue, + onValueChange = { reasonValue = it }, + label = { Text(stringResource(R.string.relay_management_reason_optional)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } else { + var userName by remember { mutableStateOf(TextFieldValue("")) } + + OutlinedTextField( + label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, + modifier = Modifier.fillMaxWidth(), + value = userName, + onValueChange = { + userName = it + userSuggestions.processCurrentWord(it.text) + }, + singleLine = true, + trailingIcon = { + if (userName.text.isNotEmpty()) { + IconButton( + onClick = { + userName = TextFieldValue("") + userSuggestions.processCurrentWord("") + }, + ) { + ClearTextIcon() + } + } + }, + ) + + ShowUserSuggestions( + userSuggestions = userSuggestions, + onSelect = { user -> + selectedUser = user + userName = TextFieldValue("") + userSuggestions.processCurrentWord("") + }, + accountViewModel = accountViewModel, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { selectedUser?.let { onConfirm(it, reasonValue.trim()) } }, + enabled = selectedUser != null, + ) { + Text(stringResource(R.string.relay_management_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.relay_management_cancel)) + } + }, + ) +} + +@Composable +private fun SelectedUserRow( + user: User, + accountViewModel: AccountViewModel, + onClear: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallBorder) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture(user, 40.dp, accountViewModel = accountViewModel, onClick = null) + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } + IconButton(onClick = onClear) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.relay_management_remove), + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun ShowUserSuggestions( + userSuggestions: UserSuggestionState, + onSelect: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + val listState = rememberLazyListState() + + AnimateOnNewSearch(userSuggestions, listState) + + val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = Modifier.heightIn(0.dp, 200.dp), + state = listState, + ) { + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = { onSelect(baseUser) }) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture(baseUser, 40.dp, accountViewModel, Modifier, null) + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { + UsernameDisplay(baseUser, accountViewModel = accountViewModel) + AboutDisplay(baseUser, accountViewModel) + } + } + + HorizontalDivider(thickness = DividerThickness) + } + } + } +} + @Composable private fun HexInputDialog( title: String, From ed31b12e3fe6a90cd3d041085f653400bbf0e97d Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 30 Mar 2026 18:50:03 +0000 Subject: [PATCH 5/5] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 4 ++++ amethyst/src/main/res/values-sl-rSI/strings.xml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 352cd7cc50..e3bca072bc 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1267,6 +1267,10 @@ Repozytorium Git: %1$s Strona internetowa: Klonuj: + Statyczna Witryna: %1$s + Strona główna + Źródło: + Serwery: OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index c9b1a3632b..04865f5e09 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1282,6 +1282,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Git skladišče: %1$s Splet: Klon: + Statična spletna stran: %1$s + Izhodiščno spletišče + Vir: + Strežniki: OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas.