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