Merge pull request #2040 from vitorpamplona/claude/migrate-textfield-state-6ezEZ

Migrate text fields from TextFieldValue to TextFieldState
This commit is contained in:
Vitor Pamplona
2026-03-30 15:58:19 -04:00
committed by GitHub
26 changed files with 652 additions and 309 deletions
@@ -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(),
)
}
}
@@ -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 =
@@ -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()
}
@@ -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(
@@ -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)
}
}
@@ -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)
}
}
}
@@ -21,6 +21,8 @@
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.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -147,7 +149,7 @@ open class CommentPostViewModel :
var notifying by mutableStateOf<List<User>?>(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<Event>()
@@ -376,7 +378,7 @@ open class CommentPostViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
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<SelectedMedia>) {
@@ -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 }) {
@@ -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.ui.text.input.TextFieldValue
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) {
@@ -24,11 +24,11 @@ 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.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -108,7 +108,8 @@ fun ChatroomView(
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage))
newPostModel.message.setTextAndPlaceCursorAtEnd(draftMessage)
newPostModel.onMessageChanged()
}
}
@@ -21,6 +21,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.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -203,7 +204,7 @@ class ChatNewMessageViewModel :
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -215,8 +216,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)
@@ -273,10 +274,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) {
@@ -290,8 +290,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
@@ -371,14 +371,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) {
@@ -392,7 +391,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) {
@@ -400,13 +399,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())
}
@@ -421,7 +421,7 @@ class ChatNewMessageViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
innerSendPost(draftTag.current)
@@ -563,11 +563,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
@@ -633,8 +634,8 @@ class ChatNewMessageViewModel :
fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
subject = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
subject.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
@@ -667,15 +668,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)
@@ -689,11 +690,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)
}
@@ -706,7 +705,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 }
@@ -720,8 +719,7 @@ class ChatNewMessageViewModel :
}
}
fun updateSubject(it: TextFieldValue) {
subject = it
fun onSubjectChanged() {
draftTag.newVersion()
}
@@ -738,14 +736,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()
}
@@ -759,8 +757,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()
@@ -776,8 +774,8 @@ class ChatNewMessageViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -794,8 +792,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() {
@@ -816,7 +814,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 }) {
@@ -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.ui.text.input.TextFieldValue
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 =
@@ -54,7 +54,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
@@ -201,8 +201,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(),
@@ -231,7 +231,7 @@ fun EditField(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
)
}
@@ -21,6 +21,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.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -137,7 +138,7 @@ open class ChannelNewMessageViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
val message = TextFieldState()
var urlPreview by mutableStateOf<String?>(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<Event>()
@@ -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<out Event>? {
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 }) {
@@ -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),
)
}
}
@@ -62,12 +62,13 @@ 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.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
@@ -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,
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
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
@@ -147,7 +149,7 @@ class LongFormPostViewModel :
var isUploadingCoverImage by mutableStateOf(false)
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
var showPreview by mutableStateOf(false)
@@ -241,7 +243,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()
}
@@ -265,7 +267,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
@@ -335,7 +337,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
@@ -349,7 +351,7 @@ class LongFormPostViewModel :
val tagger =
NewMessageTagger(
message.text,
message.text.toString(),
null,
null,
accountViewModel,
@@ -539,7 +541,7 @@ class LongFormPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
val markdownImage = "![${alt ?: ""}](${state.result.url})"
message = message.insertUrlAtCursor(markdownImage)
message.insertUrlAtCursor(markdownImage)
}
}
@@ -559,7 +561,7 @@ class LongFormPostViewModel :
title = TextFieldValue("")
summary = TextFieldValue("")
coverImageUrl = ""
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
publishedAt = TimeUtils.now()
showPreview = false
@@ -594,11 +596,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)
@@ -626,7 +626,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("")
@@ -641,7 +641,7 @@ class LongFormPostViewModel :
fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -655,21 +655,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<SelectedMedia>) {
@@ -699,7 +699,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 }) {
@@ -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.ui.text.input.TextFieldValue
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) {
@@ -21,6 +21,7 @@
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
@@ -131,7 +132,7 @@ open class NewProductViewModel :
var productImages by mutableStateOf<List<ProductImageMeta>>(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<SelectedMedia>) {
@@ -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()
}
@@ -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 =
@@ -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.ui.text.input.TextFieldValue
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) {
@@ -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.ui.text.input.TextFieldValue
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) {
@@ -37,6 +37,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
@@ -54,7 +55,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.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
@@ -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) {
@@ -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
@@ -183,7 +185,7 @@ open class ShortNotePostViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -367,7 +369,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 +387,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 +438,7 @@ open class ShortNotePostViewModel :
}
}
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -533,11 +535,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 +607,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 +681,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 +752,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<Event>()
@@ -811,7 +813,7 @@ open class ShortNotePostViewModel :
val tagger =
NewMessageTagger(
message.text,
message.text.toString(),
pTags,
eTags,
accountViewModel,
@@ -1000,8 +1002,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 +1026,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 +1044,7 @@ open class ShortNotePostViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
forkedFromNote = null
@@ -1104,15 +1106,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 +1142,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 +1159,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 +1176,8 @@ open class ShortNotePostViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -1201,7 +1203,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 +1228,7 @@ open class ShortNotePostViewModel :
}
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -1378,7 +1380,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 }) {
@@ -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 =
@@ -21,6 +21,7 @@
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
@@ -147,7 +148,7 @@ class NewPublicMessageViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(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<MultiOrchestrator?>(null)
@@ -218,10 +219,9 @@ class NewPublicMessageViewModel :
fun load(users: Set<HexKey>) {
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<Event>()
@@ -364,13 +363,14 @@ class NewPublicMessageViewModel :
}
private suspend fun createTemplate(): EventTemplate<PublicMessageEvent> {
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<SelectedMedia>) {
@@ -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 }) {
@@ -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
}