From 1d208edd33b8f91bf93b955815912f7d3776aecb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:09:38 +0000 Subject: [PATCH 01/18] feat: migrate ThinPaddingTextField to BasicTextField(TextFieldState) for GIF keyboard support Migrates all new post screens from the old BasicTextField(TextFieldValue) to the new BasicTextField(TextFieldState) API, which properly sets EditorInfo.contentMimeTypes to enable GIF keyboard input. Key changes: - Add TextFieldState-based ThinPaddingTextField overload (keep old for readonly dropdowns) - Create UrlUserTagOutputTransformation for the new OutputTransformation API - Create TextFieldState extension functions (insertUrlAtCursor, replaceCurrentWord, currentWord) - Update IMessageField interface to use TextFieldState + onMessageChanged() - Migrate all IMessageField implementors: ChannelNewMessageViewModel, ChatNewMessageViewModel, NewProductViewModel, NewPublicMessageViewModel, CommentPostViewModel, ShortNotePostViewModel, LongFormPostViewModel - Update PreviewState to accept String instead of TextFieldValue - Add TextFieldState overload to UserSuggestionState.replaceCurrentWord() - Update all post screen call sites https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ --- .../actions/UrlUserTagOutputTransformation.kt | 80 +++++++++++ .../ui/components/ThinPaddingTextField.kt | 127 ++++++++++++++++- .../creators/messagefield/IMessageField.kt | 6 +- .../creators/messagefield/MessageField.kt | 8 +- .../ui/note/creators/previews/PreviewState.kt | 9 +- .../userSuggestions/UserSuggestionState.kt | 14 ++ .../nip22Comments/CommentPostViewModel.kt | 51 +++---- .../nip22Comments/GenericCommentPostScreen.kt | 5 +- .../loggedIn/chats/privateDM/ChatroomView.kt | 5 +- .../privateDM/send/ChatNewMessageViewModel.kt | 94 ++++++------- .../chats/privateDM/send/NewGroupDMScreen.kt | 23 +-- .../send/PrivateMessageEditFieldRow.kt | 8 +- .../send/ChannelNewMessageViewModel.kt | 42 +++--- .../chats/publicChannels/send/EditFieldRow.kt | 8 +- .../nip23LongForm/LongFormPostViewModel.kt | 32 ++--- .../nip99Classifieds/NewProductScreen.kt | 5 +- .../nip99Classifieds/NewProductViewModel.kt | 93 ++++++------- .../discover/nip99Classifieds/SellProduct.kt | 34 ++--- .../loggedIn/home/ShortNotePostViewModel.kt | 65 ++++----- .../publicMessages/NewPublicMessageScreen.kt | 10 +- .../NewPublicMessageViewModel.kt | 84 ++++++----- .../compose/TextFieldStateExtensions.kt | 131 ++++++++++++++++++ 22 files changed, 636 insertions(+), 298 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt new file mode 100644 index 000000000..5728bcd6f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import kotlin.coroutines.cancellation.CancellationException + +class UrlUserTagOutputTransformation( + val color: Color, +) : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + val text = asCharSequence().toString() + + // Find all @npub mentions using regex and replace in reverse order + // so that earlier indices remain valid after replacements. + val npubRegex = Regex("@npub1[a-z0-9]{58}") + val matches = npubRegex.findAll(text).toList().reversed() + + for (match in matches) { + try { + val key = decodePublicKey(match.value.removePrefix("@")) + val user = LocalCache.getOrCreateUser(key.toHexKey()) + val displayName = "@${user.toBestDisplayName()}" + + replace(match.range.first, match.range.last + 1, displayName) + + // Apply color styling to the replaced display name + addAnnotation( + SpanStyle(color = color, textDecoration = TextDecoration.None), + match.range.first, + match.range.first + displayName.length, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + + // Highlight URLs in remaining text + highlightUrls(color) + } +} + +private fun TextFieldBuffer.highlightUrls(color: Color) { + val text = asCharSequence().toString() + val urlPattern = android.util.Patterns.WEB_URL + + val matcher = urlPattern.matcher(text) + while (matcher.find()) { + addAnnotation( + SpanStyle(color = color, textDecoration = TextDecoration.None), + matcher.start(), + matcher.end(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt index e2a31c967..6931e34c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt @@ -27,6 +27,10 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalTextStyle @@ -35,8 +39,11 @@ import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor @@ -50,6 +57,124 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText // COPIED FROM TEXT FIELD // The only change is the contentPadding below +// New TextFieldState-based overload for GIF keyboard support +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThinPaddingTextField( + state: TextFieldState, + modifier: Modifier = Modifier, + onTextChanged: (() -> Unit)? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + inputTransformation: InputTransformation? = null, + outputTransformation: OutputTransformation? = null, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource? = null, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), + contentPadding: PaddingValues = + if (label == null) { + TextFieldDefaults.contentPaddingWithoutLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + } else { + TextFieldDefaults.contentPaddingWithLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + }, +) { + @Suppress("NAME_SHADOWING") + val interactionSource = interactionSource ?: remember { MutableInteractionSource() } + + if (onTextChanged != null) { + val callback by rememberUpdatedState(onTextChanged) + LaunchedEffect(state) { + snapshotFlow { state.text } + .collect { callback() } + } + } + + val textColor = + textStyle.color.takeOrElse { + val focused by interactionSource.collectIsFocusedAsState() + when { + !enabled -> MaterialTheme.colorScheme.placeholderText + isError -> MaterialTheme.colorScheme.onSurface + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface + } + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + val lineLimits = + if (singleLine) { + TextFieldLineLimits.SingleLine + } else { + TextFieldLineLimits.MultiLine(minLines, maxLines) + } + + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + BasicTextField( + state = state, + modifier = + modifier + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = 36.dp, + ), + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = keyboardOptions, + lineLimits = lineLimits, + interactionSource = interactionSource, + inputTransformation = inputTransformation, + outputTransformation = outputTransformation, + decorator = { innerTextField -> + TextFieldDefaults.DecorationBox( + value = state.text.toString(), + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = contentPadding, + ) + }, + ) + } +} + +// Legacy TextFieldValue-based overload (kept for readonly dropdown fields) @OptIn(ExperimentalMaterial3Api::class) @Composable fun ThinPaddingTextField( @@ -76,7 +201,6 @@ fun ThinPaddingTextField( interactionSource: MutableInteractionSource? = null, shape: Shape = TextFieldDefaults.shape, colors: TextFieldColors = TextFieldDefaults.colors(), - // new fields contentPadding: PaddingValues = if (label == null) { TextFieldDefaults.contentPaddingWithoutLabel( @@ -113,6 +237,7 @@ fun ThinPaddingTextField( val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + @Suppress("DEPRECATION") BasicTextField( value = value, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt index 2b2ea5063..b21c36c09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.messagefield -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.TextFieldState interface IMessageField { - var message: TextFieldValue + val message: TextFieldState - fun updateMessage(newMessage: TextFieldValue) + fun onMessageChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index 63509dca6..adf7bc682 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -65,8 +65,8 @@ fun MessageField( } ThinPaddingTextField( - value = viewModel.message, - onValueChange = viewModel::updateMessage, + state = viewModel.message, + onTextChanged = viewModel::onMessageChanged, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -91,7 +91,7 @@ fun MessageField( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt index 9a50d8bf8..09adafb3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.previews import androidx.compose.runtime.Stable -import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.service.CachedUrlParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -33,22 +32,22 @@ import kotlinx.coroutines.flow.map @Stable class PreviewState { - var source = MutableStateFlow(TextFieldValue("")) + var source = MutableStateFlow("") @OptIn(FlowPreview::class) val results = source .debounce(500) .map { - CachedUrlParser.parseValidUrls(it.text) + CachedUrlParser.parseValidUrls(it) }.distinctUntilChanged() .flowOn(Dispatchers.IO) fun reset() { - source.tryEmit(TextFieldValue("")) + source.tryEmit("") } - fun update(text: TextFieldValue) { + fun update(text: String) { source.tryEmit(text) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4adc385d5..b1da7a1ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -199,4 +200,17 @@ class UserSuggestionState( TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), ) } + + fun replaceCurrentWord( + state: TextFieldState, + word: String, + item: User, + ) { + val wordToInsert = "@${item.pubkeyNpub()}" + state.edit { + val lastWordStart = selection.end - word.length + replace(lastWordStart, selection.end, wordToInsert) + selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index da55dd466..d3765d2e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -147,7 +149,7 @@ open class CommentPostViewModel : var notifying by mutableStateOf?>(null) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -242,7 +244,7 @@ open class CommentPostViewModel : } open fun quote(quote: Note) { - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") quote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { @@ -262,7 +264,7 @@ open class CommentPostViewModel : wantsForwardZapTo = true } - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draft: Note) { @@ -322,11 +324,11 @@ open class CommentPostViewModel : wantsForwardZapTo = true } - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } suspend fun sendPostSync() { @@ -359,7 +361,7 @@ open class CommentPostViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val attachments = mutableSetOf() @@ -376,7 +378,7 @@ open class CommentPostViewModel : private suspend fun createTemplate(): EventTemplate? { val tagger = NewMessageTagger( - message = message.text, + message = message.text.toString(), dao = accountViewModel, ) tagger.run() @@ -525,8 +527,8 @@ open class CommentPostViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) + message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message.text.toString()) } } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = @@ -549,8 +551,8 @@ open class CommentPostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(state.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message.text.toString()) } } @@ -576,7 +578,7 @@ open class CommentPostViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") replyingTo = null externalIdentity = null @@ -618,9 +620,8 @@ open class CommentPostViewModel : notifying = notifying?.filter { it != userToRemove } } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(message) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { val lastWord = message.currentWord() @@ -651,8 +652,8 @@ open class CommentPostViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -668,8 +669,8 @@ open class CommentPostViewModel : open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -685,8 +686,8 @@ open class CommentPostViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -694,14 +695,14 @@ open class CommentPostViewModel : } fun canPost(): Boolean = - message.text.isNotBlank() && + message.text.toString().isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -718,7 +719,7 @@ open class CommentPostViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 3c866565f..c861233de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -46,7 +46,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel @@ -125,7 +125,8 @@ fun ReplyCommentPostScreen( postViewModel.quote(it) } message?.ifBlank { null }?.let { - postViewModel.updateMessage(TextFieldValue(it)) + postViewModel.message.setTextAndPlaceCursorAtEnd(it) + postViewModel.onMessageChanged() } attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 358247a94..047009a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -109,7 +109,8 @@ fun ChatroomView( if (draftMessage != null) { LaunchedEffect(key1 = draftMessage) { - newPostModel.updateMessage(TextFieldValue(draftMessage)) + newPostModel.message.setTextAndPlaceCursorAtEnd(draftMessage) + newPostModel.onMessageChanged() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 13130e854..49ff9068b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -204,7 +205,7 @@ class ChatNewMessageViewModel : var uploadsWaitingToBeSent by mutableStateOf>(emptyList()) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -216,8 +217,8 @@ class ChatNewMessageViewModel : var emojiSuggestions: EmojiSuggestionState? = null - var toUsers by mutableStateOf(TextFieldValue("")) - var subject by mutableStateOf(TextFieldValue("")) + val toUsers = TextFieldState() + val subject = TextFieldState() // Invoices var canAddInvoice by mutableStateOf(false) @@ -274,10 +275,9 @@ class ChatNewMessageViewModel : fun load(room: ChatroomKey) { this.room.tryEmit(room) - this.toUsers = - TextFieldValue( - room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + this.toUsers.setTextAndPlaceCursorAtEnd( + room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) } fun reply(replyNote: Note) { @@ -291,8 +291,8 @@ class ChatNewMessageViewModel : } fun quote(quote: Note) { - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") + urlPreviews.update(message.text.toString()) // creates a split with that author. val quotedAuthor = quote.author ?: return @@ -372,14 +372,13 @@ class ChatNewMessageViewModel : } draftEvent.subject()?.let { - subject = TextFieldValue() + subject.setTextAndPlaceCursorAtEnd("") } if (draftEvent is NIP17Group) { - toUsers = - TextFieldValue( - draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + toUsers.setTextAndPlaceCursorAtEnd( + draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) val replyId = when (draftEvent) { @@ -393,7 +392,7 @@ class ChatNewMessageViewModel : } } else if (draftEvent is PrivateDmEvent) { val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } - toUsers = TextFieldValue("@$recipientNPub") + toUsers.setTextAndPlaceCursorAtEnd("@$recipientNPub") val replyId = draftEvent.replyTo() if (replyId != null) { @@ -401,13 +400,14 @@ class ChatNewMessageViewModel : } } - message = + val draftText = if (draftEvent is PrivateDmEvent) { - TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "") + accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "" } else { - TextFieldValue(draftEvent.content) + draftEvent.content } - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(draftText) + urlPreviews.update(message.text.toString()) iMetaAttachments.addAll(draftEvent.imetas()) } @@ -422,7 +422,7 @@ class ChatNewMessageViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { account.deleteDraftIgnoreErrors(draftTag.current) } else { innerSendPost(draftTag.current) @@ -564,11 +564,12 @@ class ChatNewMessageViewModel : private suspend fun innerSendPost(draftTag: String?) { val room = room.value ?: return - val urls = findURLs(message.text) + val messageText = message.text.toString() + val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) + val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null - val message = message.text + val message = messageText val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null @@ -634,8 +635,8 @@ class ChatNewMessageViewModel : fun cancel() { draftTag.rotate() - message = TextFieldValue("") - subject = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") + subject.setTextAndPlaceCursorAtEnd("") replyTo.value = null @@ -668,15 +669,15 @@ class ChatNewMessageViewModel : } fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(newMessage) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -690,11 +691,9 @@ class ChatNewMessageViewModel : draftTag.newVersion() } - fun updateToUsers(newToUsersValue: TextFieldValue) { - toUsers = newToUsersValue - - if (newToUsersValue.selection.collapsed) { - val lastWord = newToUsersValue.currentWord() + fun onToUsersChanged() { + if (toUsers.selection.collapsed) { + val lastWord = toUsers.currentWord() userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS userSuggestions?.processCurrentWord(lastWord) } @@ -707,7 +706,7 @@ class ChatNewMessageViewModel : fun updateRoomFromUsersInput() { viewModelScope.launch(Dispatchers.IO) { delay(300) - val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel) + val toUsersTagger = NewMessageTagger(toUsers.text.toString(), null, null, accountViewModel) toUsersTagger.run() val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } @@ -721,8 +720,7 @@ class ChatNewMessageViewModel : } } - fun updateSubject(it: TextFieldValue) { - subject = it + fun onSubjectChanged() { draftTag.newVersion() } @@ -739,14 +737,14 @@ class ChatNewMessageViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { val lastWord = toUsers.currentWord() - toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + userSuggestions.replaceCurrentWord(toUsers, lastWord, item) updateRoomFromUsersInput() } @@ -760,8 +758,8 @@ class ChatNewMessageViewModel : fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -777,8 +775,8 @@ class ChatNewMessageViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -795,8 +793,8 @@ class ChatNewMessageViewModel : recipientsMissingDmRelays.value.isEmpty() fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) - urlPreviews.update(message) + message.insertUrlAtCursor(newElement) + urlPreviews.update(message.text.toString()) } override fun onCleared() { @@ -817,7 +815,7 @@ class ChatNewMessageViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 5d3a7ba1d..a40f4ad11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -67,7 +67,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -80,7 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -158,7 +158,8 @@ fun NewGroupDMScreen( LaunchedEffect(postViewModel, accountViewModel) { message?.ifBlank { null }?.let { - postViewModel.updateMessage(TextFieldValue(it)) + postViewModel.message.setTextAndPlaceCursorAtEnd(it) + postViewModel.onMessageChanged() } attachment?.let { withContext(Dispatchers.IO) { @@ -530,8 +531,8 @@ fun SendDirectMessageTo( ) ThinPaddingTextField( - value = postViewModel.toUsers, - onValueChange = postViewModel::updateToUsers, + state = postViewModel.toUsers, + onTextChanged = postViewModel::onToUsersChanged, modifier = Modifier .weight(1f) @@ -547,8 +548,8 @@ fun SendDirectMessageTo( color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = @@ -572,8 +573,8 @@ fun SendDirectMessageTo( ) ThinPaddingTextField( - value = postViewModel.subject, - onValueChange = { postViewModel.updateSubject(it) }, + state = postViewModel.subject, + onTextChanged = { postViewModel.onSubjectChanged() }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -581,8 +582,8 @@ fun SendDirectMessageTo( color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 9ef4170ff..22fe288db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -202,8 +202,8 @@ fun EditField( accountViewModel: AccountViewModel, ) { ThinPaddingTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, + state = channelScreenModel.message, + onTextChanged = { channelScreenModel.onMessageChanged() }, keyboardOptions = PostKeyboard, shape = EditFieldBorder, modifier = Modifier.fillMaxWidth(), @@ -232,7 +232,7 @@ fun EditField( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 5b293355b..885a8a59b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -137,7 +138,7 @@ open class ChannelNewMessageViewModel : val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) - var message by mutableStateOf(TextFieldValue("")) + val message = TextFieldState() var urlPreview by mutableStateOf(null) val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false @@ -274,7 +275,7 @@ open class ChannelNewMessageViewModel : } } - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) @@ -302,7 +303,7 @@ open class ChannelNewMessageViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { account.deleteDraftIgnoreErrors(draftTag.current) } else { val attachments = mutableSetOf() @@ -365,14 +366,14 @@ open class ChannelNewMessageViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor(it.toNostrUri()) + message.insertUrlAtCursor(it.toNostrUri()) } urlPreview = findUrlInMessage() } else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) { iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason) - message = message.insertUrlAtCursor(upload.result.url) + message.insertUrlAtCursor(upload.result.url) urlPreview = findUrlInMessage() } } @@ -392,18 +393,19 @@ open class ChannelNewMessageViewModel : private suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null + val messageText = message.text.toString() val tagger = NewMessageTagger( - message = message.text, + message = messageText, pTags = listOfNotNull(replyTo.value?.author), eTags = listOfNotNull(replyTo.value), dao = accountViewModel, ) tagger.run() - val urls = findURLs(message.text) + val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) + val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) val channelRelays = channel.relays() val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null @@ -533,7 +535,7 @@ open class ChannelNewMessageViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") replyTo.value = null @@ -561,18 +563,18 @@ open class ChannelNewMessageViewModel : emojiSuggestions?.reset() } - open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text).withScheme.firstOrNull() + open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text.toString()).withScheme.firstOrNull() open fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - open fun updateMessage(newMessage: TextFieldValue) { - message = newMessage + open fun onMessageChanged() { urlPreview = findUrlInMessage() - if (newMessage.selection.collapsed) { - val lastWord = newMessage.currentWord() + if (message.selection.collapsed) { + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -600,7 +602,7 @@ open class ChannelNewMessageViewModel : userSuggestions?.let { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = it.replaceCurrentWord(message, lastWord, item) + it.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.addItem(item) forwardZapToEditting = TextFieldValue("") @@ -615,7 +617,7 @@ open class ChannelNewMessageViewModel : open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() @@ -631,7 +633,7 @@ open class ChannelNewMessageViewModel : } } - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() @@ -648,7 +650,7 @@ open class ChannelNewMessageViewModel : uploadState?.multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } override fun locationManager(): LocationState = Amethyst.instance.locationManager @@ -675,7 +677,7 @@ open class ChannelNewMessageViewModel : fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index ddf1ad2d4..1c2669b35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -114,8 +114,8 @@ fun EditFieldRow( } ThinPaddingTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, + state = channelScreenModel.message, + onTextChanged = { channelScreenModel.onMessageChanged() }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -151,7 +151,7 @@ fun EditFieldRow( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index ddbcdc910..a4ecc0611 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm import android.R.attr.version import android.content.Context import androidx.compose.runtime.Stable +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -148,7 +150,7 @@ class LongFormPostViewModel : var isUploadingCoverImage by mutableStateOf(false) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() var showPreview by mutableStateOf(false) @@ -242,7 +244,7 @@ class LongFormPostViewModel : summary = TextFieldValue(noteEvent.summary() ?: "") publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt coverImageUrl = noteEvent.image() ?: "" - message = TextFieldValue(noteEvent.content) + message.setTextAndPlaceCursorAtEnd(noteEvent.content) existingDTag = noteEvent.dTag() } @@ -266,7 +268,7 @@ class LongFormPostViewModel : summary = TextFieldValue(draftEvent.summary() ?: "") publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt coverImageUrl = draftEvent.image() ?: "" - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) existingDTag = draftEvent.dTag() canAddInvoice = accountViewModel.userProfile().lnAddress() != null @@ -336,7 +338,7 @@ class LongFormPostViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank() && title.text.isBlank()) { + if (message.text.toString().isBlank() && title.text.isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val template = createTemplate() ?: return @@ -350,7 +352,7 @@ class LongFormPostViewModel : val tagger = NewMessageTagger( - message.text, + message.text.toString(), null, null, accountViewModel, @@ -540,7 +542,7 @@ class LongFormPostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) val markdownImage = "![${alt ?: ""}](${state.result.url})" - message = message.insertUrlAtCursor(markdownImage) + message.insertUrlAtCursor(markdownImage) } } @@ -560,7 +562,7 @@ class LongFormPostViewModel : title = TextFieldValue("") summary = TextFieldValue("") coverImageUrl = "" - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") publishedAt = TimeUtils.now() showPreview = false @@ -595,11 +597,9 @@ class LongFormPostViewModel : this.multiOrchestrator?.remove(selected) } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - + override fun onMessageChanged() { if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -627,7 +627,7 @@ class LongFormPostViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) + userSuggestions.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -642,7 +642,7 @@ class LongFormPostViewModel : fun autocompleteWithEmoji(item: EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() draftTag.newVersion() } @@ -656,21 +656,21 @@ class LongFormPostViewModel : } } - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() draftTag.newVersion() } fun canPost(): Boolean = title.text.isNotBlank() && - message.text.isNotBlank() && + message.text.toString().isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index ef9287527..b1da22259 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel @@ -112,7 +112,8 @@ fun NewProductScreen( postViewModel.quote(it) } message?.ifBlank { null }?.let { - postViewModel.updateMessage(TextFieldValue(it)) + postViewModel.message.setTextAndPlaceCursorAtEnd(it) + postViewModel.onMessageChanged() } attachment?.ifBlank { null }?.toUri()?.let { withContext(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 826fa4eaa..ba9dbb4a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -131,7 +132,7 @@ open class NewProductViewModel : var productImages by mutableStateOf>(emptyList()) val iMetaDescription = IMetaAttachments() - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -151,10 +152,10 @@ open class NewProductViewModel : val strippingFailureConfirmation = SuspendableConfirmation() // Classifieds - var title by mutableStateOf(TextFieldValue("")) - var price by mutableStateOf(TextFieldValue("")) - var locationText by mutableStateOf(TextFieldValue("")) - var category by mutableStateOf(TextFieldValue("")) + val title = TextFieldState() + val price = TextFieldState() + val locationText = TextFieldState() + val category = TextFieldState() var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) // Invoices @@ -226,7 +227,7 @@ open class NewProductViewModel : open fun quote(quote: Note) { val accountViewModel = accountViewModel - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") quote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { @@ -246,7 +247,7 @@ open class NewProductViewModel : wantsForwardZapTo = true } - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draft: Note) { @@ -284,10 +285,10 @@ open class NewProductViewModel : zapRaiserAmount.value = zapraiser } - title = TextFieldValue(draftEvent.title() ?: "") - price = TextFieldValue(draftEvent.price()?.amount ?: "") - category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "") - locationText = TextFieldValue(draftEvent.location() ?: "") + title.setTextAndPlaceCursorAtEnd(draftEvent.title() ?: "") + price.setTextAndPlaceCursorAtEnd(draftEvent.price()?.amount ?: "") + category.setTextAndPlaceCursorAtEnd(draftEvent.categories().firstOrNull() ?: "") + locationText.setTextAndPlaceCursorAtEnd(draftEvent.location() ?: "") condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW val imageSet = draftEvent.images().toMutableSet() @@ -305,9 +306,9 @@ open class NewProductViewModel : productImages = productImages + ProductImageMeta(it) } - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } suspend fun sendPostSync() { @@ -323,7 +324,7 @@ open class NewProductViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val template = createTemplate() ?: return @@ -336,7 +337,7 @@ open class NewProductViewModel : val tagger = NewMessageTagger( - message = message.text, + message = message.text.toString(), dao = accountViewModel, ) tagger.run() @@ -356,15 +357,15 @@ open class NewProductViewModel : val template = ClassifiedsEvent.build( - title.text, - PriceTag(price.text, "SATS", null), + title.text.toString(), + PriceTag(price.text.toString(), "SATS", null), tagger.message, - locationText.text.ifBlank { null }, + locationText.text.toString().ifBlank { null }, condition, ) { productImages.forEach { image(it.url) } - hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message)) + hashtags(listOfNotNull(category.text.toString().ifBlank { null }) + findHashtags(tagger.message)) quotes(quotes) geoHash?.let { geohash(it) } @@ -438,8 +439,8 @@ open class NewProductViewModel : } else { iMetaDescription.add(it.result, alt, contentWarningReason) - message = message.insertUrlAtCursor(it.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(it.result.url) + urlPreviews.update(message.text.toString()) } } } @@ -458,7 +459,7 @@ open class NewProductViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") multiOrchestrator = null mediaUploadTracker.finishUpload() @@ -468,10 +469,10 @@ open class NewProductViewModel : zapRaiserAmount.value = null condition = ConditionTag.CONDITION.USED_LIKE_NEW - locationText = TextFieldValue("") - title = TextFieldValue("") - category = TextFieldValue("") - price = TextFieldValue("") + locationText.setTextAndPlaceCursorAtEnd("") + title.setTextAndPlaceCursorAtEnd("") + category.setTextAndPlaceCursorAtEnd("") + price.setTextAndPlaceCursorAtEnd("") wantsForwardZapTo = false wantsToMarkAsSensitive = false @@ -505,9 +506,8 @@ open class NewProductViewModel : this.multiOrchestrator?.remove(selected) } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(message) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { val lastWord = message.currentWord() @@ -538,8 +538,8 @@ open class NewProductViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -555,8 +555,8 @@ open class NewProductViewModel : open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -572,8 +572,8 @@ open class NewProductViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -591,7 +591,7 @@ open class NewProductViewModel : multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -620,7 +620,7 @@ open class NewProductViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { @@ -648,19 +648,11 @@ open class NewProductViewModel : draftTag.newVersion() } - fun updateTitle(it: TextFieldValue) { - title = it + fun onTitleChanged() { draftTag.newVersion() } - fun updatePrice(it: TextFieldValue) { - runCatching { - if (it.text.isEmpty()) { - price = TextFieldValue("") - } else if (it.text.toLongOrNull() != null) { - price = it - } - } + fun onPriceChanged() { draftTag.newVersion() } @@ -669,13 +661,12 @@ open class NewProductViewModel : draftTag.newVersion() } - fun updateCategory(value: TextFieldValue) { - category = value + fun updateCategory(text: String) { + category.setTextAndPlaceCursorAtEnd(text) draftTag.newVersion() } - fun updateLocation(it: TextFieldValue) { - locationText = it + fun onLocationChanged() { draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index 590a3872f..9cb2e4aec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -44,12 +44,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TextFieldValue // kept for readonly dropdown ThinPaddingTextField import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.TitleExplainer @@ -109,10 +109,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { ) ThinPaddingTextField( - value = postViewModel.title, - onValueChange = { - postViewModel.updateTitle(it) - }, + state = postViewModel.title, + onTextChanged = { postViewModel.onTitleChanged() }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -120,8 +118,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = @@ -146,10 +144,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( modifier = Modifier.fillMaxWidth(), - value = postViewModel.price, - onValueChange = { - postViewModel.updatePrice(it) - }, + state = postViewModel.price, + onTextChanged = { postViewModel.onPriceChanged() }, placeholder = { Text( text = "1000", @@ -275,10 +271,10 @@ fun SellProduct(postViewModel: NewProductViewModel) { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() } TextSpinner( - placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "", + placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text.toString() }?.second ?: "", options = categoryOptions, onSelect = { - postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second)) + postViewModel.updateCategory(categoryTypes[it].second) }, modifier = Modifier @@ -313,10 +309,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { ) ThinPaddingTextField( - value = postViewModel.locationText, - onValueChange = { - postViewModel.updateLocation(it) - }, + state = postViewModel.locationText, + onTextChanged = { postViewModel.onLocationChanged() }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -324,8 +318,8 @@ fun SellProduct(postViewModel: NewProductViewModel) { color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 11601d54c..83176c5c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -28,7 +28,8 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -183,7 +184,7 @@ open class ShortNotePostViewModel : val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -367,7 +368,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null quote?.let { quotedNote -> - message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}") + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}") quotedNote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != user.pubkeyHex) { @@ -385,7 +386,7 @@ open class ShortNotePostViewModel : } fork?.let { forkedNoted -> - message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "") + message.setTextAndPlaceCursorAtEnd(version?.event?.content ?: forkedNoted.event?.content ?: "") forkedNoted.event?.isSensitiveOrNSFW()?.let { if (it) wantsToMarkAsSensitive = true @@ -436,7 +437,7 @@ open class ShortNotePostViewModel : } } - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draft: Note) { @@ -533,11 +534,11 @@ open class ShortNotePostViewModel : wantsPoll = false wantsZapPoll = false - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draftEvent: PollEvent) { @@ -605,11 +606,11 @@ open class ShortNotePostViewModel : pollType = draftEvent.pollType() closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } private fun loadFromDraft(draftEvent: ZapPollEvent) { @@ -679,11 +680,11 @@ open class ShortNotePostViewModel : zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() } zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead() - message = TextFieldValue(draftEvent.content) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) iMetaAttachments.addAll(draftEvent.imetas()) - urlPreviews.update(message) + urlPreviews.update(message.text.toString()) } suspend fun sendPostSync() { @@ -750,7 +751,7 @@ open class ShortNotePostViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val attachments = mutableSetOf() @@ -811,7 +812,7 @@ open class ShortNotePostViewModel : val tagger = NewMessageTagger( - message.text, + message.text.toString(), pTags, eTags, accountViewModel, @@ -1000,8 +1001,8 @@ open class ShortNotePostViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) + message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message.text.toString()) } } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = @@ -1024,8 +1025,8 @@ open class ShortNotePostViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(state.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message.text.toString()) } } @@ -1042,7 +1043,7 @@ open class ShortNotePostViewModel : open fun cancel() { draftTag.rotate() - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") forkedFromNote = null @@ -1104,15 +1105,15 @@ open class ShortNotePostViewModel : } open fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(message) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -1140,8 +1141,8 @@ open class ShortNotePostViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -1157,8 +1158,8 @@ open class ShortNotePostViewModel : open fun autocompleteWithEmoji(item: EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -1174,8 +1175,8 @@ open class ShortNotePostViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -1201,7 +1202,7 @@ open class ShortNotePostViewModel : } // Regular text/media posts require text - return message.text.isNotBlank() && + return message.text.toString().isNotBlank() && !mediaUploadTracker.isUploading && !isUploadingVoice && !wantsInvoice && @@ -1226,7 +1227,7 @@ open class ShortNotePostViewModel : } fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -1378,7 +1379,7 @@ open class ShortNotePostViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { val tagger = - NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index f6ab68d8a..ada38e5e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -415,8 +415,8 @@ fun SendDirectMessageTo( ) ThinPaddingTextField( - value = postViewModel.toUsers, - onValueChange = postViewModel::updateToUsers, + state = postViewModel.toUsers, + onTextChanged = postViewModel::onToUsersChanged, modifier = Modifier .weight(1f) @@ -432,8 +432,8 @@ fun SendDirectMessageTo( color = MaterialTheme.colorScheme.placeholderText, ) }, - visualTransformation = - UrlUserTagTransformation( + outputTransformation = + UrlUserTagOutputTransformation( MaterialTheme.colorScheme.primary, ), colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 28daa1bf2..fdc74178e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -147,7 +148,7 @@ class NewPublicMessageViewModel : val iMetaAttachments = IMetaAttachments() var nip95attachments by mutableStateOf>>(emptyList()) - override var message by mutableStateOf(TextFieldValue("")) + override val message = TextFieldState() val urlPreviews = PreviewState() @@ -160,7 +161,7 @@ class NewPublicMessageViewModel : var emojiSuggestions: EmojiSuggestionState? = null - var toUsers by mutableStateOf(TextFieldValue("")) + val toUsers = TextFieldState() // Images and Videos var multiOrchestrator by mutableStateOf(null) @@ -218,10 +219,9 @@ class NewPublicMessageViewModel : fun load(users: Set) { val userSet = users - account.userProfile().pubkeyHex - toUsers = - TextFieldValue( - userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + toUsers.setTextAndPlaceCursorAtEnd( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) } fun reply(post: Note) { @@ -229,8 +229,8 @@ class NewPublicMessageViewModel : } fun quote(quote: Note) { - message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") + urlPreviews.update(message.text.toString()) // creates a split with that author. val quotedAuthor = quote.author ?: return @@ -306,13 +306,12 @@ class NewPublicMessageViewModel : val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex - toUsers = - TextFieldValue( - userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, - ) + toUsers.setTextAndPlaceCursorAtEnd( + userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) - message = TextFieldValue(draftEvent.content) - urlPreviews.update(message) + message.setTextAndPlaceCursorAtEnd(draftEvent.content) + urlPreviews.update(message.text.toString()) iMetaAttachments.addAll(draftEvent.imetas()) } @@ -349,7 +348,7 @@ class NewPublicMessageViewModel : } suspend fun sendDraftSync() { - if (message.text.isBlank()) { + if (message.text.toString().isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { val broadcast = mutableSetOf() @@ -364,13 +363,14 @@ class NewPublicMessageViewModel : } private suspend fun createTemplate(): EventTemplate { - val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel) + val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text.toString(), null, null, accountViewModel) toUsersTagger.run() + val messageText = message.text.toString() val msg = replyingTo?.let { - it.toNostrUri() + "\n" + message.text - } ?: message.text + it.toNostrUri() + "\n" + messageText + } ?: messageText val tagger = NewMessageTagger(msg, null, null, accountViewModel) tagger.run() @@ -470,8 +470,8 @@ class NewPublicMessageViewModel : val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { - message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) - urlPreviews.update(message) + message.insertUrlAtCursor("nostr:" + it.toNEvent()) + urlPreviews.update(message.text.toString()) } } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMeta = @@ -494,8 +494,8 @@ class NewPublicMessageViewModel : iMetaAttachments.replace(iMeta.url, iMeta) - message = message.insertUrlAtCursor(state.result.url) - urlPreviews.update(message) + message.insertUrlAtCursor(state.result.url) + urlPreviews.update(message.text.toString()) } } @@ -512,8 +512,8 @@ class NewPublicMessageViewModel : fun cancel() { draftTag.rotate() - toUsers = TextFieldValue("") - message = TextFieldValue("") + toUsers.setTextAndPlaceCursorAtEnd("") + message.setTextAndPlaceCursorAtEnd("") multiOrchestrator = null wantsInvoice = false @@ -546,15 +546,15 @@ class NewPublicMessageViewModel : } fun addToMessage(it: String) { - updateMessage(TextFieldValue(message.text + " " + it)) + message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) + onMessageChanged() } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - urlPreviews.update(newMessage) + override fun onMessageChanged() { + urlPreviews.update(message.text.toString()) if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -569,11 +569,9 @@ class NewPublicMessageViewModel : draftTag.newVersion() } - fun updateToUsers(newToUsersValue: TextFieldValue) { - toUsers = newToUsersValue - - if (newToUsersValue.selection.collapsed) { - val lastWord = newToUsersValue.currentWord() + fun onToUsersChanged() { + if (toUsers.selection.collapsed) { + val lastWord = toUsers.currentWord() userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS userSuggestions?.processCurrentWord(lastWord) } @@ -594,14 +592,14 @@ class NewPublicMessageViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) - urlPreviews.update(message) + userSuggestions.replaceCurrentWord(message, lastWord, item) + urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { val lastWord = toUsers.currentWord() - toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + userSuggestions.replaceCurrentWord(toUsers, lastWord, item) } userSuggestionsMainMessage = null @@ -614,8 +612,8 @@ class NewPublicMessageViewModel : fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -631,8 +629,8 @@ class NewPublicMessageViewModel : } } - message = message.replaceCurrentWord(wordToInsert) - urlPreviews.update(message) + message.replaceCurrentWord(wordToInsert) + urlPreviews.update(message.text.toString()) emojiSuggestions?.reset() @@ -648,7 +646,7 @@ class NewPublicMessageViewModel : multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -671,7 +669,7 @@ class NewPublicMessageViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) + val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt new file mode 100644 index 000000000..6b74453af --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.compose + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.text.TextRange +import kotlin.math.max +import kotlin.math.min + +fun TextFieldState.insertUrlAtCursor(url: String) { + edit { + var toInsert = url.trim() + val selStart = selection.start + val selEnd = selection.end + val text = asCharSequence() + + if (selStart > 0 && text[selStart - 1] != ' ' && text[selStart - 1] != '\n') { + toInsert = " $toInsert" + } + + // takes the position before adding an empty char after the url + val endOfUrlIndex = selStart + toInsert.length + + if (selEnd < length && text[selEnd] != ' ' && text[selEnd] != '\n') { + toInsert = "$toInsert " + } + + replace(selStart, selEnd, toInsert) + selection = TextRange(endOfUrlIndex, endOfUrlIndex) + } +} + +fun TextFieldState.replaceCurrentWord(wordToInsert: String) { + edit { + val text = asCharSequence() + val selStart = selection.start + val selEnd = selection.end + val lastWordStart = currentWordStartIdx(text, selStart) + val lastWordEnd = currentWordEndIdx(text, selEnd, length) + val cursor = lastWordStart + wordToInsert.length + replace(lastWordStart, lastWordEnd, wordToInsert) + selection = TextRange(cursor, cursor) + } +} + +fun TextFieldState.currentWord(): String { + val text = this.text + val selection = this.selection + if (selection.end != selection.start) return "" + + val start = currentWordStartIdx(text, selection.start) + val end = currentWordEndIdx(text, selection.end, text.length) + + return if (start < end) { + text.subSequence(start, end).toString() + } else { + "" + } +} + +private fun currentWordStartIdx( + text: CharSequence, + selectionStart: Int, +): Int { + val previousNewLine = text.lastIndexOf('\n', selectionStart - 1) + val previousSpace = text.lastIndexOf(' ', selectionStart - 1) + + return max( + previousNewLine, + previousSpace, + ) + 1 +} + +private fun currentWordEndIdx( + text: CharSequence, + selectionEnd: Int, + textLength: Int, +): Int { + val nextNewLine = text.indexOf('\n', selectionEnd) + val nextSpace = text.indexOf(' ', selectionEnd) + + if (nextSpace < 0 && nextNewLine < 0) return selectionEnd + if (nextSpace > 0 && nextNewLine > 0) { + return min( + nextNewLine, + nextSpace, + ) + } + if (nextSpace > 0) { + return nextSpace + } + return nextNewLine +} + +private fun CharSequence.lastIndexOf( + char: Char, + startIndex: Int, +): Int { + for (i in startIndex downTo 0) { + if (this[i] == char) return i + } + return -1 +} + +private fun CharSequence.indexOf( + char: Char, + startIndex: Int, +): Int { + for (i in startIndex until length) { + if (this[i] == char) return i + } + return -1 +} From f997586e4b40e8cc754c8896b37194c8a78c4056 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:11:09 +0000 Subject: [PATCH 02/18] 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 664331698..925d818d9 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 a4ecc0611..7b4a82391 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 7eb071ecc..f04ac3543 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 46aad80cf..d66c7ec60 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 b0c589058..c088d42c8 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 83176c5c4..f117f1124 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 03/18] 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 d3765d2e1..a488bde7a 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 c861233de..e8d4bb98f 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 047009a1a..10c960c87 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 49ff9068b..818fdfcd4 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 a40f4ad11..a7a84109a 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 885a8a59b..4be84c8be 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 925d818d9..7eb346617 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 7b4a82391..127c2073f 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 b1da22259..e0c13365e 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 ba9dbb4a7..ee4dd6c2f 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 9cb2e4aec..c13346b81 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 f04ac3543..def8f5dc0 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 d66c7ec60..962b13e45 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 c088d42c8..9a3e27ee6 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 f117f1124..9d7e7fd93 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 fdc74178e..9fc9d0fcc 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 04/18] 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 5c7827b58..5c6346fdc 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 bc491b154bb3a74159c66279bb427befca2f3db7 Mon Sep 17 00:00:00 2001 From: Nguyen Van Nam Date: Mon, 30 Mar 2026 22:48:41 +0700 Subject: [PATCH 05/18] docs: undocumented identity-based comparison in `equalimmutablelists` `equalImmutableLists` is public and uses referential equality (`===`) per element rather than structural equality (`==`). Without documentation, callers may assume normal list equality and get false negatives for value-equal but distinct instances. This is a non-obvious contract and should be documented explicitly to prevent subtle logic bugs. Affected files: ListUtils.kt Signed-off-by: Nguyen Van Nam --- .../com/vitorpamplona/amethyst/commons/utils/ListUtils.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt index d70e2417f..8c1412096 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt @@ -22,6 +22,14 @@ package com.vitorpamplona.amethyst.commons.utils import kotlinx.collections.immutable.ImmutableList +/** + * Compares two [ImmutableList] instances by element identity (`===`), not value equality (`==`). + * + * Returns `true` only when both lists have the same size and each element at the same index + * is the exact same object reference. + * + * Useful for cheap change detection in UI/state pipelines where object identity is meaningful. + */ fun equalImmutableLists( list1: ImmutableList, list2: ImmutableList, From 0c4e5be1ea9ae605be2dc6745892ff8653347481 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 30 Mar 2026 12:29:41 -0400 Subject: [PATCH 06/18] v1.07.0 --- .claude/skills/android-expert/SKILL.md | 2 +- .claude/skills/quartz-integration/SKILL.md | 6 +- .../references/gradle-setup.md | 8 +- .claude/skills/quartz-kmp.md | 2 +- CHANGELOG.md | 179 +++++++++++++++++- amethyst/build.gradle | 6 +- quartz/build.gradle.kts | 4 +- 7 files changed, 190 insertions(+), 17 deletions(-) diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index fc9693e92..1b4ab3d12 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -746,7 +746,7 @@ android { minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 versionCode = 435 - versionName = "1.06.3" + versionName = "1.07.0" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index e0d193b0f..cbebd5361 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.0` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.06.3" +quartz = "1.07.0" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.3") + implementation("com.vitorpamplona.quartz:quartz:1.07.0") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index eae28c616..ff4de4965 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.06.3 +com.vitorpamplona.quartz:quartz:1.07.0 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.06.3" +quartz = "1.07.0" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.3") + implementation("com.vitorpamplona.quartz:quartz:1.07.0") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.3") + implementation("com.vitorpamplona.quartz:quartz:1.07.0") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index 3ebfdd9b7..686fad655 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.06.3 +com.vitorpamplona.quartz:quartz:1.07.0 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a96915bac..f51fc19c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,178 @@ + +# [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30 + +Amethyst: +- Adds support to Pin Notes +- Adds support to Polls feed screen +- Adds support for Requests to Vanish +- Adds support for Relay management (NIP-86) from Amethyst +- Adds support for Relay monitor assessments (NIP-66) in the Relay Info screen. +- Adds support for Relay member information (NIP-43) in the Relay Info screen +- Adds support for WebBookmarks +- Adds support for Zap Goals + +Quartz: +- Rewords the NostrClient API for simpler commands. +- Adds NIP-15: Nostr Marketplace protocol — product listings, stalls, merchant events (#2020) +- Adds NIP-24: Birthday field support added to UserMetadata (#1979) +- Adds NIP-29: Relay-based groups — group events, member management, moderation (#2021) +- Adds NIP-32: Labeling protocol — LabelEvent and tag parsing (#1975) +- Adds NIP-43: Relay access metadata and membership management with UI screens (#2010) +- Adds NIP-60: Cashu wallet and spending history event support (#2009) +- Adds NIP-61: Nutzaps protocol support (#2008) +- Adds NIP-62: Request to Vanish feature — data deletion with relay compliance testing (#1958) +- Adds NIP-66: Relay monitor discovery events — UI, filter assembler, subscription composable (#1968) +- Adds NIP-69: P2P Order Events (kind 38383) (#2019) +- Adds NIP-75: Zap Goals support with creation and rendering (#1965) +- Adds NIP-77: Negentropy set reconciliation protocol support (#1955) +- Adds NIP-7D: Thread events (kind 11) rendering (#2016) +- Adds NIP-85: Trusted Assertions — assertions for events and addressables (#1981) +- Adds NIP-86: Relay management UI and client implementation (#1954) +- Adds NIP-87: Ecash mint discoverability — Cashu and Fedimint event types (#2015) +- Adds NIP-89: Compliance fixes, PlatformLinkTag parsing, app handler extensions (#2030) +- Adds NIP-90: All DVM kind event classes from data-vending-machines spec, restructured packages (#2023, #2025) +- Adds NIP-A4: Add k tag to zap requests and enforce e tag prohibition (#1978) +- Adds NIP-BE: Bluetooth Low Energy mesh networking / Nostr BLE Communications Protocol (#2022) +- Adds NIP-C7: Chat messages (kind 9) implementation (#2018) +- Adds NIP-5A: Static website event rendering (#2017) +- Adds NIP-51, kind 10001: Pinned notes feature (#1956) + +Improvements and Fixes: +- Show toast instead of dialog on media download success +- Dynamically adjust preferred Blossom server when list changes +- Add relay discovery to node master rendering list +- Add scroll to settings page +- Solves crashing when multiple relays with the same url are included in the resulting list. +- Keep screen on during PiP playback and survive screen lock (#1999) +- Desktop feed loading — missing events, broken profile navigation (#2027) +- Use getOrCreateNote for reply linking to fix flaky thread test (#2027) +- Route ReadsScreen following-mode events through cache (#2027) +- Pin ElectrumX server certs for Samsung One UI 7 / Android 16 compatibility (#1937) +- Duplicate keys in relay management lazy column — sort pubkeys +- URL detector — fixes localhost:3030 strings, Japanese character URLs +- Web Bookmarks floating action button shape (circle) and open graph previews. +- NIP-86 requests now send Accept and Content-Type headers +- On DMs, activates decryption for all filetypes that match decryption url with the cipher info, not only binaries +- Adds a try/finally to subscriptions to make sure they close even in crashes. +- Protects against crashes when the signer sends an unverifiable payload back to Amethyst + +Desktop: +- Cache-centric architecture for desktop feeds (#1905) +- Render reposts and quoted notes in feed (#2027) + +Performance +- Faster startup procedures with less loading on the main thread. +- 20x Faster Rfc3986Normalizer and way less objects being created. +- Url Detector without using regex +- Parallelize preference file access at startup for faster launch +- Remove internal runBlocking calls +- Lazy loading the memory trimming service +- Speeding up DrawerContent rendering +- Eagerly delete intermediate temp files in upload pipeline + +Refactoring: +- Simplify NostrClient API for beginner-friendliness (#1986) +- Simplify relay API with AutoCloseable and serve() helper (#2031) +- URL detector performance and readability improvements (#2013) +- Restructure NIP-90 DVMs to match NIP-88 Polls pattern (#2023) +- Reorganize NIP-BE into subpackages +- Move public messages and trusted assertions out of experimental + +Migrations & Deprecation Fixes + +- Migrate `LocalClipboardManager` to `LocalClipboard` (#1995) +- Migrate `LocalAutofillTree` to semantics-based Autofill API (#1996) +- Migrate `TabRow`/`ScrollableTabRow` to Material 3 Secondary variants (#1994) +- Replace `ContextCompat.startActivity` with `Context.startActivity` (#1993) +- Suppress deprecation and unchecked cast warnings in Quartz internal code (#1985) + +Platform & Build + +- Add linuxX64 target and restructure native source sets +- Add explicit permissions to GitHub Actions workflows +- Stop Gradle daemons after build steps +- Increase Android CI build timeout to 45 minutes +- Update video compression library to latest +- Update dependencies, add local maven repo for easier library development +- Remove libsodium files +- Git hooks installation in worktrees (.git file vs directory) +- Fixes lack of proxy access to download VLC files in claude's web environment. +- Log level filtering — LogLevel enum, PlatformLog, lambda overloads for 194+ Log.d() calls (#2005) +- Parallelize BaseDBTest forEachDB using coroutines + +Documentation + +- Add CLIENT.md guide for building Nostr clients with Quartz +- Add RELAY.md guide for building relays with Ktor, NostrServer, SQLite + +Contributors + +- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z — Primary development +- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef — Log level filtering, video compression, toast downloads, CI fixes, git worktree fix +- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c — Desktop cache architecture, desktop bugfixes +- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 — CI workflow permissions +- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k — Remove libsodium files +- **mstrofnone** — ElectrumX cert pinning for Samsung One UI 7 + +Translations + +- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp +- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz +- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm +- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 +- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw +- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t +- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 +- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h +- Russian by Anton Zhao + + +# [Release v1.06.3: Anon Posting](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.3) - 2026-03-24 + +Improvements to Anon Posting and Reply navigation on DMs + + +# [Release v1.06.2: Multi-choice Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.2) - 2026-03-24 + +- Add poll type selector for single and multiple choice polls +- Add drag-to-seek support on the video progress indicator +- Hide video controls on playback start +- Recording indicator bar stops recording when tapped, not just the small stop icon. +- Fixes non http uris in the references tag +- Makes sure quote tags only happen once in events. +- Removes the clickable NIP-05 URL in the @ tagging of users +- Reduces the need to compute naddr to index articles in bookmarks +- Checks if p tags are user pubkeys when loading drafts. +- Scroll to the replied message when clicking reply preview in chat +- Add an anonymous reply with a throwaway keypair +- Fixes pool rendering when one label is large and the other is small +- Use uri-reference-kmp in commonMain. Remove platform-specific implementations. +- Avoids crashing the app in URLs with japanese chars +- Sorts followers into a set to avoid LazyColumn key conflicts. +- Clickable relay rows on the profile page +- Move cache lookups from NavHost route lambdas into screen composables +- Improvements to the status of attestations. Validity first, then Processing status. +- Fixes Tor Manager flow value and improves Tor binding lifecycle. +- Simplifies the report feed in the user profile +- Clears some inconsistencies in translated strings +- Fixes client tags +- Fixes routes for AppDefinition events. + + +# [Release v1.06.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.1) - 2026-03-23 + +- Improvements to the rendering of Polls. +- Solves some of the crashes of the concurrent modification exception +- Fixes URL parsers with Japanese chars +- Fixes Wallet import from Primal on Poco phones +- Improves the wording of the Last Seen +- Fixes for "Cannot disable reuse from root if it was caused by other groups" +- Fixes comparator to avoid Comparison method violates its general contract! +- Improves zap-store settings + -# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21 +# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2026-03-21 Polls: - Adds support for creating and rendering NIP-85 @@ -271,13 +444,13 @@ Updated translations: - Russian by Anton Zhao -# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 +# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08 - Fixed mixed DMs between logged in users. - Fixed draft screen click to edit post. -# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 +# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08 #Amethyst v1.05.0: Bookmark Lists, Voice Notes, and WoT Scores diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 24b923d5d..6275a7d86 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -54,9 +54,9 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 435 - versionName = generateVersionName("1.06.3") - buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\"" + versionCode = 436 + versionName = generateVersionName("1.07.0") + buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index adb968e98..c359007e5 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -120,7 +120,7 @@ kotlin { // SQLite KMP driver for event store api(libs.androidx.sqlite) implementation(libs.androidx.sqlite.bundled) - + // Negentropy set reconciliation (NIP-77) api(libs.negentropy.kmp) } @@ -315,7 +315,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.06.3", + version = "1.07.0", ) // Configure publishing to Maven Central From ef8b9be0e88efe93e30a39575dc09dd965d8afca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 17:37:23 +0000 Subject: [PATCH 07/18] refactor: replace synchronized with Channel + AtomicBoolean in BLE relay classes Use Channel(UNLIMITED) as a lock-free send queue and AtomicBoolean with atomic exchange for the isSending flag, eliminating all synchronized blocks while maintaining thread safety via a double-check pattern. https://claude.ai/code/session_01KLRz7FJgWRtgCcZe3qD1VX --- .../quartz/nipBEBle/relay/BleNostrClient.kt | 51 ++++++++++--------- .../quartz/nipBEBle/relay/BleNostrServer.kt | 46 +++++++++-------- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt index 371b701d7..2c75d9576 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt @@ -31,6 +31,10 @@ import com.vitorpamplona.quartz.nipBEBle.protocol.BleChunkAssembler import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * A Nostr relay client that communicates over BLE instead of WebSockets. @@ -54,6 +58,7 @@ import com.vitorpamplona.quartz.utils.Log * client.sendIfConnected(EventCmd(myEvent)) * ``` */ +@OptIn(ExperimentalAtomicApi::class, ExperimentalCoroutinesApi::class) class BleNostrClient( val peer: BlePeer, val transport: BleTransport, @@ -64,8 +69,8 @@ class BleNostrClient( private var connected = false private val assembler = BleChunkAssembler() - private val sendQueue = ArrayDeque>() - private var isSending = false + private val sendQueue = Channel>(Channel.UNLIMITED) + private val isSending = AtomicBoolean(false) override fun isConnected(): Boolean = connected @@ -96,12 +101,9 @@ class BleNostrClient( val json = OptimizedJsonMapper.toJson(cmd) val chunks = BleMessageChunker.splitIntoChunks(json, chunkSize) - synchronized(sendQueue) { - sendQueue.addLast(chunks) - if (!isSending) { - isSending = true - sendNextMessage() - } + sendQueue.trySend(chunks) + if (!isSending.exchange(true)) { + sendNextMessage() } listener.onSent(this, json, cmd, true) @@ -110,10 +112,7 @@ class BleNostrClient( override fun disconnect() { connected = false assembler.reset() - synchronized(sendQueue) { - sendQueue.clear() - isSending = false - } + drainQueue() transport.disconnectFromPeer(peer) listener.onDisconnected(this) } @@ -132,10 +131,7 @@ class BleNostrClient( fun onDisconnected() { connected = false assembler.reset() - synchronized(sendQueue) { - sendQueue.clear() - isSending = false - } + drainQueue() listener.onDisconnected(this) } @@ -165,21 +161,28 @@ class BleNostrClient( private var currentChunkIndex = 0 private fun sendNextMessage() { - val chunks = - synchronized(sendQueue) { - sendQueue.removeFirstOrNull() - } - if (chunks == null) { - synchronized(sendQueue) { - isSending = false + val result = sendQueue.tryReceive() + if (result.isFailure) { + isSending.store(false) + // Double-check: an item may have been added after tryReceive + // but before we cleared the flag. + if (sendQueue.isEmpty) return + if (!isSending.exchange(true)) { + sendNextMessage() } return } - currentChunks = chunks + currentChunks = result.getOrNull() currentChunkIndex = 0 sendNextChunk() } + private fun drainQueue() { + while (sendQueue.tryReceive().isSuccess) {} + currentChunks = null + isSending.store(false) + } + private fun sendNextChunk() { val chunks = currentChunks ?: return if (currentChunkIndex >= chunks.size) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt index 2a0b493e6..5a8d6d39b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt @@ -29,6 +29,10 @@ import com.vitorpamplona.quartz.nipBEBle.protocol.BleChunkAssembler import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** * Handles the server (relay) side of a BLE Nostr connection. @@ -52,6 +56,7 @@ import com.vitorpamplona.quartz.utils.Log * server.sendMessage(EventMessage(subId, event)) * ``` */ +@OptIn(ExperimentalAtomicApi::class, ExperimentalCoroutinesApi::class) class BleNostrServer( val peer: BlePeer, val transport: BleTransport, @@ -60,8 +65,8 @@ class BleNostrServer( ) { private var connected = false private val assembler = BleChunkAssembler() - private val sendQueue = ArrayDeque>() - private var isSending = false + private val sendQueue = Channel>(Channel.UNLIMITED) + private val isSending = AtomicBoolean(false) fun isConnected(): Boolean = connected @@ -79,10 +84,7 @@ class BleNostrServer( fun onClientDisconnected() { connected = false assembler.reset() - synchronized(sendQueue) { - sendQueue.clear() - isSending = false - } + drainQueue() listener.onClientDisconnected(this) } @@ -110,12 +112,9 @@ class BleNostrServer( val json = OptimizedJsonMapper.toJson(msg) val chunks = BleMessageChunker.splitIntoChunks(json, chunkSize) - synchronized(sendQueue) { - sendQueue.addLast(chunks) - if (!isSending) { - isSending = true - sendNextMessage() - } + sendQueue.trySend(chunks) + if (!isSending.exchange(true)) { + sendNextMessage() } } @@ -139,21 +138,28 @@ class BleNostrServer( private var currentChunkIndex = 0 private fun sendNextMessage() { - val chunks = - synchronized(sendQueue) { - sendQueue.removeFirstOrNull() - } - if (chunks == null) { - synchronized(sendQueue) { - isSending = false + val result = sendQueue.tryReceive() + if (result.isFailure) { + isSending.store(false) + // Double-check: an item may have been added after tryReceive + // but before we cleared the flag. + if (sendQueue.isEmpty) return + if (!isSending.exchange(true)) { + sendNextMessage() } return } - currentChunks = chunks + currentChunks = result.getOrNull() currentChunkIndex = 0 sendNextChunk() } + private fun drainQueue() { + while (sendQueue.tryReceive().isSuccess) {} + currentChunks = null + isSending.store(false) + } + private fun sendNextChunk() { val chunks = currentChunks ?: return if (currentChunkIndex >= chunks.size) { From 13bd492e32b4ee617ce035530236d149e7acfa27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 17:48:58 +0000 Subject: [PATCH 08/18] refactor: replace synchronized with lock-free CAS in BleChunkAssembler Use AtomicReference holding an immutable list with a compare-and-set loop, eliminating the synchronized block while maintaining thread safety. https://claude.ai/code/session_01KLRz7FJgWRtgCcZe3qD1VX --- .../nipBEBle/protocol/BleChunkAssembler.kt | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt index dfd154708..c27f465f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt @@ -20,37 +20,43 @@ */ package com.vitorpamplona.quartz.nipBEBle.protocol +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi + /** * Accumulates incoming BLE chunks for a single message and reassembles * them once all chunks have arrived. * - * Thread-safe: all access to internal state is synchronized. + * Thread-safe: uses a lock-free compare-and-set loop over an immutable list. */ +@OptIn(ExperimentalAtomicApi::class) class BleChunkAssembler { - private val lock = Any() - private val receivedChunks = mutableListOf() + private val receivedChunks = AtomicReference(emptyList()) /** * Adds a received chunk. If this completes the message, returns the * reassembled JSON string. Otherwise returns null. */ - fun addChunk(chunk: ByteArray): String? = - synchronized(lock) { - receivedChunks.add(chunk) - if (BleMessageChunker.isComplete(receivedChunks)) { - val message = BleMessageChunker.joinChunks(receivedChunks.toTypedArray()) - receivedChunks.clear() - message + fun addChunk(chunk: ByteArray): String? { + while (true) { + val current = receivedChunks.load() + val updated = current + chunk + if (BleMessageChunker.isComplete(updated)) { + if (receivedChunks.compareAndSet(current, emptyList())) { + return BleMessageChunker.joinChunks(updated.toTypedArray()) + } } else { - null + if (receivedChunks.compareAndSet(current, updated)) { + return null + } } } + } /** * Discards any partially received chunks. */ - fun reset() = - synchronized(lock) { - receivedChunks.clear() - } + fun reset() { + receivedChunks.store(emptyList()) + } } From 640c47e29fc8e7db2012267af6fa07da7112b5e9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 30 Mar 2026 13:53:25 -0400 Subject: [PATCH 09/18] v1.07.1 --- .claude/skills/android-expert/SKILL.md | 2 +- .claude/skills/quartz-integration/SKILL.md | 6 +++--- .../skills/quartz-integration/references/gradle-setup.md | 8 ++++---- .claude/skills/quartz-kmp.md | 2 +- amethyst/build.gradle | 4 ++-- quartz/build.gradle.kts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 1b4ab3d12..2743aa33a 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -746,7 +746,7 @@ android { minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 versionCode = 435 - versionName = "1.07.0" + versionName = "1.07.1" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index cbebd5361..45ccc1138 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.0` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.1` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.07.0" +quartz = "1.07.1" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.07.0") + implementation("com.vitorpamplona.quartz:quartz:1.07.1") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index ff4de4965..83834f816 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.07.0 +com.vitorpamplona.quartz:quartz:1.07.1 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.07.0" +quartz = "1.07.1" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.07.0") + implementation("com.vitorpamplona.quartz:quartz:1.07.1") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.07.0") + implementation("com.vitorpamplona.quartz:quartz:1.07.1") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index 686fad655..d4f1985f4 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.07.0 +com.vitorpamplona.quartz:quartz:1.07.1 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 6275a7d86..23b7ecec6 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -54,8 +54,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 436 - versionName = generateVersionName("1.07.0") + versionCode = 437 + versionName = generateVersionName("1.07.1") buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index c359007e5..ee24a901d 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -315,7 +315,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.07.0", + version = "1.07.1", ) // Configure publishing to Maven Central From ed31b12e3fe6a90cd3d041085f653400bbf0e97d Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 30 Mar 2026 18:50:03 +0000 Subject: [PATCH 10/18] 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 352cd7cc5..e3bca072b 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 c9b1a3632..04865f5e0 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. From b02b8c4ed935931a320c1f027d8573a9810cb5cd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 30 Mar 2026 14:57:13 -0400 Subject: [PATCH 11/18] new change logs --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f51fc19c0..944cefb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ + +# [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30 + +- New translations +- Fixes BLE's module need for syncronized events. + # [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30 From 4b9c3f239cc271cc83ca648b844d48a9db5a04d5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 30 Mar 2026 16:02:55 -0400 Subject: [PATCH 12/18] Fixes version mismatch --- .../amethyst/ui/actions/UrlUserTagOutputTransformation.kt | 4 ++-- .../loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt | 1 + .../chats/publicChannels/send/ChannelNewMessageViewModel.kt | 1 + .../loggedIn/discover/nip99Classifieds/NewProductViewModel.kt | 1 + .../notifications/publicMessages/NewPublicMessageViewModel.kt | 1 + 5 files changed, 6 insertions(+), 2 deletions(-) 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 index 5728bcd6f..40bcb20fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -50,7 +50,7 @@ class UrlUserTagOutputTransformation( replace(match.range.first, match.range.last + 1, displayName) // Apply color styling to the replaced display name - addAnnotation( + addStyle( SpanStyle(color = color, textDecoration = TextDecoration.None), match.range.first, match.range.first + displayName.length, @@ -71,7 +71,7 @@ private fun TextFieldBuffer.highlightUrls(color: Color) { val matcher = urlPattern.matcher(text) while (matcher.find()) { - addAnnotation( + addStyle( SpanStyle(color = color, textDecoration = TextDecoration.None), matcher.start(), matcher.end(), 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 ebc89b0e3..5b83b81b6 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send 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 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 e3ae1d131..317262c9e 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send 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 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 f6d82b154..392024de8 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds 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 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 df7377853..09cd8f47e 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessag 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 From f2d007f9ba3bbfdf04b74b9154cdaf63f2fa984c Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Mon, 30 Mar 2026 17:09:59 -0300 Subject: [PATCH 13/18] fix: support external signers wrapped in NostrSignerWithClientTag The external signer registration logic was failing when the signer was wrapped in a `NostrSignerWithClientTag`. Fix by: 1. Updating `ListenToExternalSignerIfNeeded` to unwrap the signer if it's a `NostrSignerWithClientTag` containing an `IActivityLauncher` 2. Using the unwrapped launcher for intent registration and response handling 3. Renaming the intent callback to `intentLauncher` to avoid shadowing the `ActivityResultLauncher` variable --- .../ui/screen/loggedIn/LoggedInPage.kt | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 2fef9419e..7b842cae1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.launch @@ -165,7 +166,22 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { @Composable private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { - if (accountViewModel.account.signer is IActivityLauncher) { + val externalSignerLauncher = + when (val signer = accountViewModel.account.signer) { + is IActivityLauncher -> { + signer + } + + is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> { + signer.inner as IActivityLauncher + } + + else -> { + null + } + } + + if (externalSignerLauncher != null) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), @@ -173,7 +189,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { if (result.resultCode == Activity.RESULT_OK) { result.data?.let { accountViewModel.runOnIO { - accountViewModel.account.signer.newResponse(it) + externalSignerLauncher.newResponse(it) } } } @@ -181,7 +197,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { ) DisposableEffect(accountViewModel, accountViewModel.account, launcher) { - val launcher: (Intent) -> Unit = { intent -> + val intentLauncher: (Intent) -> Unit = { intent -> try { launcher.launch(intent) } catch (e: ActivityNotFoundException) { @@ -193,9 +209,9 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { } } - accountViewModel.account.signer.registerForegroundLauncher(launcher) + externalSignerLauncher.registerForegroundLauncher(intentLauncher) onDispose { - accountViewModel.account.signer.unregisterForegroundLauncher(launcher) + externalSignerLauncher.unregisterForegroundLauncher(intentLauncher) } } } From 198e0498733d9ec9a2b2343cb6332f765e6c2a98 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Mon, 30 Mar 2026 17:18:02 -0300 Subject: [PATCH 14/18] Assign `rejected` based on the presence of the "rejected" key in intent extras rather than retrieving its specific boolean value --- .../api/foreground/intents/results/IntentResult.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt index e6b233964..3cb6635d6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt @@ -52,12 +52,7 @@ data class IntentResult( result = data.getStringExtra("result"), event = data.getStringExtra("event"), `package` = data.getStringExtra("package"), - rejected = - if (data.extras?.containsKey("rejected") == true) { - data.getBooleanExtra("rejected", false) - } else { - null - }, + rejected = data.extras?.containsKey("rejected"), ) fun fromJson(json: String): IntentResult = JsonMapperNip55.fromJsonTo(json) From b625ac6d2df6e1f2b37b5b6d094cbf3924c1ede2 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 30 Mar 2026 20:26:29 +0000 Subject: [PATCH 15/18] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-sl-rSI/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 04865f5e0..f9912f954 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -226,7 +226,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kopiraj javni ključ (NPub) v odložišče Pošlji direktno sporočilo Uredi uporabnikove metapodatke - Že sledim + Sledi Sledi nazaj Deblokiraj Kopiraj uporabnikov ID From 95d678f62f4d3fa34ef699dfcd49d3acdc25ceb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 20:33:55 +0000 Subject: [PATCH 16/18] feat: Add GIF keyboard support to short note post screen Enable the message field to receive images/GIFs from Android's keyboard using Compose Foundation's contentReceiver API. When content is received, it's routed through the existing selectImage flow for upload. https://claude.ai/code/session_019GfY3MtCPcYn3wqTHCMSNu --- .../ui/components/ThinPaddingTextField.kt | 43 ++++++++++++++++++- .../creators/messagefield/MessageField.kt | 3 ++ .../loggedIn/home/ShortNotePostScreen.kt | 7 +++ 3 files changed, 51 insertions(+), 2 deletions(-) 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 6931e34c6..cc3eb1814 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 @@ -20,6 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.content.MediaType +import androidx.compose.foundation.content.ReceiveContentListener +import androidx.compose.foundation.content.TransferableContent +import androidx.compose.foundation.content.consume +import androidx.compose.foundation.content.contentReceiver +import androidx.compose.foundation.content.hasMediaType import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.PaddingValues @@ -58,12 +66,13 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText // The only change is the contentPadding below // New TextFieldState-based overload for GIF keyboard support -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ThinPaddingTextField( state: TextFieldState, modifier: Modifier = Modifier, onTextChanged: (() -> Unit)? = null, + onContentReceived: ((Uri, String?) -> Unit)? = null, enabled: Boolean = true, readOnly: Boolean = false, textStyle: TextStyle = LocalTextStyle.current, @@ -131,11 +140,41 @@ fun ThinPaddingTextField( TextFieldLineLimits.MultiLine(minLines, maxLines) } + val contentModifier = + if (onContentReceived != null) { + modifier.contentReceiver( + object : ReceiveContentListener { + override fun onReceive(transferableContent: TransferableContent): TransferableContent? { + if (!transferableContent.hasMediaType(MediaType.Image)) { + return transferableContent + } + val remaining = + transferableContent.consume { item -> + val uri = item.uri + if (uri != null) { + onContentReceived( + uri, + transferableContent.clipEntry.clipData.description + .getMimeType(0), + ) + true + } else { + false + } + } + return remaining + } + }, + ) + } else { + modifier + } + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { BasicTextField( state = state, modifier = - modifier + contentModifier .defaultMinSize( minWidth = TextFieldDefaults.MinWidth, minHeight = 36.dp, 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 adf7bc682..36cdecdcd 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.messagefield +import android.net.Uri import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle @@ -51,6 +52,7 @@ fun MessageField( placeholder: Int, viewModel: IMessageField, requestFocus: Boolean = true, + onContentReceived: ((Uri, String?) -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -67,6 +69,7 @@ fun MessageField( ThinPaddingTextField( state = viewModel.message, onTextChanged = viewModel::onMessageChanged, + onContentReceived = onContentReceived, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, 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 f207f33af..625119771 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 @@ -323,6 +323,13 @@ private fun NewPostScreenBody( MessageField( R.string.what_s_on_your_mind, postViewModel, + onContentReceived = { uri, mimeType -> + postViewModel.selectImage( + persistentListOf( + SelectedMedia(uri, mimeType), + ), + ) + }, ) } } From 332bfcd88a32373a2b5abedbd1575d7059bca92f Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 30 Mar 2026 20:39:46 +0000 Subject: [PATCH 17/18] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-sl-rSI/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 04865f5e0..f9912f954 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -226,7 +226,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Kopiraj javni ključ (NPub) v odložišče Pošlji direktno sporočilo Uredi uporabnikove metapodatke - Že sledim + Sledi Sledi nazaj Deblokiraj Kopiraj uporabnikov ID From 8e4ee7a61875f1eaed5518ce28e9913dd89a31e2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 30 Mar 2026 16:46:01 -0400 Subject: [PATCH 18/18] v1.07.2 --- .claude/skills/android-expert/SKILL.md | 4 ++-- .claude/skills/quartz-integration/SKILL.md | 6 +++--- .../quartz-integration/references/gradle-setup.md | 8 ++++---- .claude/skills/quartz-kmp.md | 2 +- CHANGELOG.md | 11 ++++++++++- amethyst/build.gradle | 4 ++-- quartz/build.gradle.kts | 2 +- 7 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 2743aa33a..e99df4ac3 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -745,8 +745,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 - versionCode = 435 - versionName = "1.07.1" + versionCode = 438 + versionName = "1.07.2" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 45ccc1138..962ac0fe9 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.1` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.2` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.07.1" +quartz = "1.07.2" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.07.1") + implementation("com.vitorpamplona.quartz:quartz:1.07.2") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index 83834f816..d2c954b3e 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.07.1 +com.vitorpamplona.quartz:quartz:1.07.2 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.07.1" +quartz = "1.07.2" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.07.1") + implementation("com.vitorpamplona.quartz:quartz:1.07.2") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.07.1") + implementation("com.vitorpamplona.quartz:quartz:1.07.2") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index d4f1985f4..de372a5a2 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.07.1 +com.vitorpamplona.quartz:quartz:1.07.2 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 944cefb2a..f449dddb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,17 @@ + +# [Release v1.07.2: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.2) - 2026-03-30 + +- Adds GIF uploads support from Keyboard +- Migrates text fields and @ modifiers to the new Jetpack Compose states +- Fixes Bug that wasn't openning Amber to sign +- Fixes Bug on rejections using old Ambers +- Replaces hex input with user search dialog in relay management (allow/ban user) + # [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30 - New translations -- Fixes BLE's module need for syncronized events. +- Fixes BLE's module need for synchronized events. # [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30 diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 23b7ecec6..5bbb9f734 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -54,8 +54,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 437 - versionName = generateVersionName("1.07.1") + versionCode = 438 + versionName = generateVersionName("1.07.2") buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index ee24a901d..0008b4376 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -315,7 +315,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.07.1", + version = "1.07.2", ) // Configure publishing to Maven Central