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
This commit is contained in:
Claude
2026-03-28 19:09:38 +00:00
parent 7b1c28c4b7
commit 1d208edd33
22 changed files with 636 additions and 298 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.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
@@ -35,8 +39,11 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -50,6 +57,124 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
// COPIED FROM TEXT FIELD // COPIED FROM TEXT FIELD
// The only change is the contentPadding below // 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ThinPaddingTextField( fun ThinPaddingTextField(
@@ -76,7 +201,6 @@ fun ThinPaddingTextField(
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape, shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(), colors: TextFieldColors = TextFieldDefaults.colors(),
// new fields
contentPadding: PaddingValues = contentPadding: PaddingValues =
if (label == null) { if (label == null) {
TextFieldDefaults.contentPaddingWithoutLabel( TextFieldDefaults.contentPaddingWithoutLabel(
@@ -113,6 +237,7 @@ fun ThinPaddingTextField(
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
@Suppress("DEPRECATION")
BasicTextField( BasicTextField(
value = value, value = value,
modifier = modifier =
@@ -20,10 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.creators.messagefield package com.vitorpamplona.amethyst.ui.note.creators.messagefield
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.foundation.text.input.TextFieldState
interface IMessageField { 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.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp 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.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -65,8 +65,8 @@ fun MessageField(
} }
ThinPaddingTextField( ThinPaddingTextField(
value = viewModel.message, state = viewModel.message,
onValueChange = viewModel::updateMessage, onTextChanged = viewModel::onMessageChanged,
keyboardOptions = keyboardOptions =
KeyboardOptions.Default.copy( KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences, capitalization = KeyboardCapitalization.Sentences,
@@ -91,7 +91,7 @@ fun MessageField(
focusedBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent,
), ),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
contentPadding = contentPadding =
TextFieldDefaults.contentPaddingWithoutLabel( TextFieldDefaults.contentPaddingWithoutLabel(
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.note.creators.previews package com.vitorpamplona.amethyst.ui.note.creators.previews
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.service.CachedUrlParser import com.vitorpamplona.amethyst.service.CachedUrlParser
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -33,22 +32,22 @@ import kotlinx.coroutines.flow.map
@Stable @Stable
class PreviewState { class PreviewState {
var source = MutableStateFlow(TextFieldValue("")) var source = MutableStateFlow("")
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
val results = val results =
source source
.debounce(500) .debounce(500)
.map { .map {
CachedUrlParser.parseValidUrls(it.text) CachedUrlParser.parseValidUrls(it)
}.distinctUntilChanged() }.distinctUntilChanged()
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
fun reset() { fun reset() {
source.tryEmit(TextFieldValue("")) source.tryEmit("")
} }
fun update(text: TextFieldValue) { fun update(text: String) {
source.tryEmit(text) source.tryEmit(text)
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -199,4 +200,17 @@ class UserSuggestionState(
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), 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,12 +21,14 @@
package com.vitorpamplona.amethyst.ui.note.nip22Comments package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Context 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.Stable
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
@@ -147,7 +149,7 @@ open class CommentPostViewModel :
var notifying by mutableStateOf<List<User>?>(null) var notifying by mutableStateOf<List<User>?>(null)
override var message by mutableStateOf(TextFieldValue("")) override val message = TextFieldState()
val urlPreviews = PreviewState() val urlPreviews = PreviewState()
@@ -242,7 +244,7 @@ open class CommentPostViewModel :
} }
open fun quote(quote: Note) { open fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser -> quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -262,7 +264,7 @@ open class CommentPostViewModel :
wantsForwardZapTo = true wantsForwardZapTo = true
} }
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
private fun loadFromDraft(draft: Note) { private fun loadFromDraft(draft: Note) {
@@ -322,11 +324,11 @@ open class CommentPostViewModel :
wantsForwardZapTo = true wantsForwardZapTo = true
} }
message = TextFieldValue(draftEvent.content) message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
suspend fun sendPostSync() { suspend fun sendPostSync() {
@@ -359,7 +361,7 @@ open class CommentPostViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank()) { if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
val attachments = mutableSetOf<Event>() val attachments = mutableSetOf<Event>()
@@ -376,7 +378,7 @@ open class CommentPostViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? { private suspend fun createTemplate(): EventTemplate<out Event>? {
val tagger = val tagger =
NewMessageTagger( NewMessageTagger(
message = message.text, message = message.text.toString(),
dao = accountViewModel, dao = accountViewModel,
) )
tagger.run() tagger.run()
@@ -525,8 +527,8 @@ open class CommentPostViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let { note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta = val iMeta =
@@ -549,8 +551,8 @@ open class CommentPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta) iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url) message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
} }
@@ -576,7 +578,7 @@ open class CommentPostViewModel :
open fun cancel() { open fun cancel() {
draftTag.rotate() draftTag.rotate()
message = TextFieldValue("") message.setTextAndPlaceCursorAtEnd("")
replyingTo = null replyingTo = null
externalIdentity = null externalIdentity = null
@@ -618,9 +620,8 @@ open class CommentPostViewModel :
notifying = notifying?.filter { it != userToRemove } notifying = notifying?.filter { it != userToRemove }
} }
override fun updateMessage(newMessage: TextFieldValue) { override fun onMessageChanged() {
message = newMessage urlPreviews.update(message.text.toString())
urlPreviews.update(message)
if (message.selection.collapsed) { if (message.selection.collapsed) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
@@ -651,8 +652,8 @@ open class CommentPostViewModel :
userSuggestions?.let { userSuggestions -> userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item) userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item) forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
@@ -668,8 +669,8 @@ open class CommentPostViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:" val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -685,8 +686,8 @@ open class CommentPostViewModel :
} }
} }
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -694,14 +695,14 @@ open class CommentPostViewModel :
} }
fun canPost(): Boolean = fun canPost(): Boolean =
message.text.isNotBlank() && message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading && !mediaUploadTracker.isUploading &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) && (!wantsZapraiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null multiOrchestrator == null
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message.insertUrlAtCursor(newElement)
} }
fun selectImage(uris: ImmutableList<SelectedMedia>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -718,7 +719,7 @@ open class CommentPostViewModel :
override fun updateZapFromText() { override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tagger = val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run() tagger.run()
tagger.pTags?.forEach { taggedUser -> tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) { if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -46,7 +46,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -125,7 +125,8 @@ fun ReplyCommentPostScreen(
postViewModel.quote(it) postViewModel.quote(it)
} }
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it)) postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
} }
attachment?.ifBlank { null }?.toUri()?.let { attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -29,7 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -109,7 +109,8 @@ fun ChatroomView(
if (draftMessage != null) { if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) { LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage)) newPostModel.message.setTextAndPlaceCursorAtEnd(draftMessage)
newPostModel.onMessageChanged()
} }
} }
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -204,7 +205,7 @@ class ChatNewMessageViewModel :
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList()) var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
override var message by mutableStateOf(TextFieldValue("")) override val message = TextFieldState()
val urlPreviews = PreviewState() val urlPreviews = PreviewState()
@@ -216,8 +217,8 @@ class ChatNewMessageViewModel :
var emojiSuggestions: EmojiSuggestionState? = null var emojiSuggestions: EmojiSuggestionState? = null
var toUsers by mutableStateOf(TextFieldValue("")) val toUsers = TextFieldState()
var subject by mutableStateOf(TextFieldValue("")) val subject = TextFieldState()
// Invoices // Invoices
var canAddInvoice by mutableStateOf(false) var canAddInvoice by mutableStateOf(false)
@@ -274,10 +275,9 @@ class ChatNewMessageViewModel :
fun load(room: ChatroomKey) { fun load(room: ChatroomKey) {
this.room.tryEmit(room) this.room.tryEmit(room)
this.toUsers = this.toUsers.setTextAndPlaceCursorAtEnd(
TextFieldValue( room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, )
)
} }
fun reply(replyNote: Note) { fun reply(replyNote: Note) {
@@ -291,8 +291,8 @@ class ChatNewMessageViewModel :
} }
fun quote(quote: Note) { fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message) urlPreviews.update(message.text.toString())
// creates a split with that author. // creates a split with that author.
val quotedAuthor = quote.author ?: return val quotedAuthor = quote.author ?: return
@@ -372,14 +372,13 @@ class ChatNewMessageViewModel :
} }
draftEvent.subject()?.let { draftEvent.subject()?.let {
subject = TextFieldValue() subject.setTextAndPlaceCursorAtEnd("")
} }
if (draftEvent is NIP17Group) { if (draftEvent is NIP17Group) {
toUsers = toUsers.setTextAndPlaceCursorAtEnd(
TextFieldValue( draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, )
)
val replyId = val replyId =
when (draftEvent) { when (draftEvent) {
@@ -393,7 +392,7 @@ class ChatNewMessageViewModel :
} }
} else if (draftEvent is PrivateDmEvent) { } else if (draftEvent is PrivateDmEvent) {
val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
toUsers = TextFieldValue("@$recipientNPub") toUsers.setTextAndPlaceCursorAtEnd("@$recipientNPub")
val replyId = draftEvent.replyTo() val replyId = draftEvent.replyTo()
if (replyId != null) { if (replyId != null) {
@@ -401,13 +400,14 @@ class ChatNewMessageViewModel :
} }
} }
message = val draftText =
if (draftEvent is PrivateDmEvent) { if (draftEvent is PrivateDmEvent) {
TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "") accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: ""
} else { } else {
TextFieldValue(draftEvent.content) draftEvent.content
} }
urlPreviews.update(message) message.setTextAndPlaceCursorAtEnd(draftText)
urlPreviews.update(message.text.toString())
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
} }
@@ -422,7 +422,7 @@ class ChatNewMessageViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank()) { if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current) account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
innerSendPost(draftTag.current) innerSendPost(draftTag.current)
@@ -564,11 +564,12 @@ class ChatNewMessageViewModel :
private suspend fun innerSendPost(draftTag: String?) { private suspend fun innerSendPost(draftTag: String?) {
val room = room.value ?: return 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 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 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 contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val localExpirationDate = if (wantsExpirationDate) expirationDate else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null
@@ -634,8 +635,8 @@ class ChatNewMessageViewModel :
fun cancel() { fun cancel() {
draftTag.rotate() draftTag.rotate()
message = TextFieldValue("") message.setTextAndPlaceCursorAtEnd("")
subject = TextFieldValue("") subject.setTextAndPlaceCursorAtEnd("")
replyTo.value = null replyTo.value = null
@@ -668,15 +669,15 @@ class ChatNewMessageViewModel :
} }
fun addToMessage(it: String) { fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it)) message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
} }
override fun updateMessage(newMessage: TextFieldValue) { override fun onMessageChanged() {
message = newMessage urlPreviews.update(message.text.toString())
urlPreviews.update(newMessage)
if (message.selection.collapsed) { if (message.selection.collapsed) {
val lastWord = newMessage.currentWord() val lastWord = message.currentWord()
if (lastWord.startsWith("@")) { if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord) userSuggestions?.processCurrentWord(lastWord)
@@ -690,11 +691,9 @@ class ChatNewMessageViewModel :
draftTag.newVersion() draftTag.newVersion()
} }
fun updateToUsers(newToUsersValue: TextFieldValue) { fun onToUsersChanged() {
toUsers = newToUsersValue if (toUsers.selection.collapsed) {
val lastWord = toUsers.currentWord()
if (newToUsersValue.selection.collapsed) {
val lastWord = newToUsersValue.currentWord()
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
userSuggestions?.processCurrentWord(lastWord) userSuggestions?.processCurrentWord(lastWord)
} }
@@ -707,7 +706,7 @@ class ChatNewMessageViewModel :
fun updateRoomFromUsersInput() { fun updateRoomFromUsersInput() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
delay(300) delay(300)
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel) val toUsersTagger = NewMessageTagger(toUsers.text.toString(), null, null, accountViewModel)
toUsersTagger.run() toUsersTagger.run()
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex } val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
@@ -721,8 +720,7 @@ class ChatNewMessageViewModel :
} }
} }
fun updateSubject(it: TextFieldValue) { fun onSubjectChanged() {
subject = it
draftTag.newVersion() draftTag.newVersion()
} }
@@ -739,14 +737,14 @@ class ChatNewMessageViewModel :
userSuggestions?.let { userSuggestions -> userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item) userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item) forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.currentWord() val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
updateRoomFromUsersInput() updateRoomFromUsersInput()
} }
@@ -760,8 +758,8 @@ class ChatNewMessageViewModel :
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:" val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -777,8 +775,8 @@ class ChatNewMessageViewModel :
} }
} }
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -795,8 +793,8 @@ class ChatNewMessageViewModel :
recipientsMissingDmRelays.value.isEmpty() recipientsMissingDmRelays.value.isEmpty()
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message.insertUrlAtCursor(newElement)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
override fun onCleared() { override fun onCleared() {
@@ -817,7 +815,7 @@ class ChatNewMessageViewModel :
override fun updateZapFromText() { override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run() tagger.run()
tagger.pTags?.forEach { taggedUser -> tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) { if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -67,7 +67,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight 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.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser 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.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -158,7 +158,8 @@ fun NewGroupDMScreen(
LaunchedEffect(postViewModel, accountViewModel) { LaunchedEffect(postViewModel, accountViewModel) {
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it)) postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
} }
attachment?.let { attachment?.let {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -530,8 +531,8 @@ fun SendDirectMessageTo(
) )
ThinPaddingTextField( ThinPaddingTextField(
value = postViewModel.toUsers, state = postViewModel.toUsers,
onValueChange = postViewModel::updateToUsers, onTextChanged = postViewModel::onToUsersChanged,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
@@ -547,8 +548,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
visualTransformation = outputTransformation =
UrlUserTagTransformation( UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary,
), ),
colors = colors =
@@ -572,8 +573,8 @@ fun SendDirectMessageTo(
) )
ThinPaddingTextField( ThinPaddingTextField(
value = postViewModel.subject, state = postViewModel.subject,
onValueChange = { postViewModel.updateSubject(it) }, onTextChanged = { postViewModel.onSubjectChanged() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text( Text(
@@ -581,8 +582,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
visualTransformation = outputTransformation =
UrlUserTagTransformation( UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary,
), ),
colors = colors =
@@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog 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.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -202,8 +202,8 @@ fun EditField(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
ThinPaddingTextField( ThinPaddingTextField(
value = channelScreenModel.message, state = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) }, onTextChanged = { channelScreenModel.onMessageChanged() },
keyboardOptions = PostKeyboard, keyboardOptions = PostKeyboard,
shape = EditFieldBorder, shape = EditFieldBorder,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -232,7 +232,7 @@ fun EditField(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
), ),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
) )
} }
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -137,7 +138,7 @@ open class ChannelNewMessageViewModel :
val iMetaAttachments = IMetaAttachments() val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList()) var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue("")) val message = TextFieldState()
var urlPreview by mutableStateOf<String?>(null) var urlPreview by mutableStateOf<String?>(null)
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: 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()) iMetaAttachments.addAll(draftEvent.imetas())
@@ -302,7 +303,7 @@ open class ChannelNewMessageViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank()) { if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current) account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
val attachments = mutableSetOf<Event>() val attachments = mutableSetOf<Event>()
@@ -365,14 +366,14 @@ open class ChannelNewMessageViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let { note?.let {
message = message.insertUrlAtCursor(it.toNostrUri()) message.insertUrlAtCursor(it.toNostrUri())
} }
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) { } else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason) iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
message = message.insertUrlAtCursor(upload.result.url) message.insertUrlAtCursor(upload.result.url)
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
} }
} }
@@ -392,18 +393,19 @@ open class ChannelNewMessageViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? { private suspend fun createTemplate(): EventTemplate<out Event>? {
val channel = channel ?: return null val channel = channel ?: return null
val messageText = message.text.toString()
val tagger = val tagger =
NewMessageTagger( NewMessageTagger(
message = message.text, message = messageText,
pTags = listOfNotNull(replyTo.value?.author), pTags = listOfNotNull(replyTo.value?.author),
eTags = listOfNotNull(replyTo.value), eTags = listOfNotNull(replyTo.value),
dao = accountViewModel, dao = accountViewModel,
) )
tagger.run() tagger.run()
val urls = findURLs(message.text) val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) 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 channelRelays = channel.relays()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
@@ -533,7 +535,7 @@ open class ChannelNewMessageViewModel :
open fun cancel() { open fun cancel() {
draftTag.rotate() draftTag.rotate()
message = TextFieldValue("") message.setTextAndPlaceCursorAtEnd("")
replyTo.value = null replyTo.value = null
@@ -561,18 +563,18 @@ open class ChannelNewMessageViewModel :
emojiSuggestions?.reset() 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) { open fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it)) message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
} }
open fun updateMessage(newMessage: TextFieldValue) { open fun onMessageChanged() {
message = newMessage
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
if (newMessage.selection.collapsed) { if (message.selection.collapsed) {
val lastWord = newMessage.currentWord() val lastWord = message.currentWord()
if (lastWord.startsWith("@")) { if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord) userSuggestions?.processCurrentWord(lastWord)
@@ -600,7 +602,7 @@ open class ChannelNewMessageViewModel :
userSuggestions?.let { userSuggestions?.let {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
message = it.replaceCurrentWord(message, lastWord, item) it.replaceCurrentWord(message, lastWord, item)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.addItem(item) forwardZapTo.addItem(item)
forwardZapToEditting = TextFieldValue("") forwardZapToEditting = TextFieldValue("")
@@ -615,7 +617,7 @@ open class ChannelNewMessageViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:" val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -631,7 +633,7 @@ open class ChannelNewMessageViewModel :
} }
} }
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -648,7 +650,7 @@ open class ChannelNewMessageViewModel :
uploadState?.multiOrchestrator == null uploadState?.multiOrchestrator == null
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message.insertUrlAtCursor(newElement)
} }
override fun locationManager(): LocationState = Amethyst.instance.locationManager override fun locationManager(): LocationState = Amethyst.instance.locationManager
@@ -675,7 +677,7 @@ open class ChannelNewMessageViewModel :
fun updateZapFromText() { fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run() tagger.run()
tagger.pTags?.forEach { taggedUser -> tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.items.any { it.key == 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 androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog 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.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -114,8 +114,8 @@ fun EditFieldRow(
} }
ThinPaddingTextField( ThinPaddingTextField(
value = channelScreenModel.message, state = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) }, onTextChanged = { channelScreenModel.onMessageChanged() },
keyboardOptions = keyboardOptions =
KeyboardOptions.Default.copy( KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences, capitalization = KeyboardCapitalization.Sentences,
@@ -151,7 +151,7 @@ fun EditFieldRow(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
), ),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
) )
} }
} }
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
import android.R.attr.version import android.R.attr.version
import android.content.Context import android.content.Context
import androidx.compose.runtime.Stable 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.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -148,7 +150,7 @@ class LongFormPostViewModel :
var isUploadingCoverImage by mutableStateOf(false) var isUploadingCoverImage by mutableStateOf(false)
override var message by mutableStateOf(TextFieldValue("")) override val message = TextFieldState()
var showPreview by mutableStateOf(false) var showPreview by mutableStateOf(false)
@@ -242,7 +244,7 @@ class LongFormPostViewModel :
summary = TextFieldValue(noteEvent.summary() ?: "") summary = TextFieldValue(noteEvent.summary() ?: "")
publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt
coverImageUrl = noteEvent.image() ?: "" coverImageUrl = noteEvent.image() ?: ""
message = TextFieldValue(noteEvent.content) message.setTextAndPlaceCursorAtEnd(noteEvent.content)
existingDTag = noteEvent.dTag() existingDTag = noteEvent.dTag()
} }
@@ -266,7 +268,7 @@ class LongFormPostViewModel :
summary = TextFieldValue(draftEvent.summary() ?: "") summary = TextFieldValue(draftEvent.summary() ?: "")
publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt
coverImageUrl = draftEvent.image() ?: "" coverImageUrl = draftEvent.image() ?: ""
message = TextFieldValue(draftEvent.content) message.setTextAndPlaceCursorAtEnd(draftEvent.content)
existingDTag = draftEvent.dTag() existingDTag = draftEvent.dTag()
canAddInvoice = accountViewModel.userProfile().lnAddress() != null canAddInvoice = accountViewModel.userProfile().lnAddress() != null
@@ -336,7 +338,7 @@ class LongFormPostViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank() && title.text.isBlank()) { if (message.text.toString().isBlank() && title.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
val template = createTemplate() ?: return val template = createTemplate() ?: return
@@ -350,7 +352,7 @@ class LongFormPostViewModel :
val tagger = val tagger =
NewMessageTagger( NewMessageTagger(
message.text, message.text.toString(),
null, null,
null, null,
accountViewModel, accountViewModel,
@@ -540,7 +542,7 @@ class LongFormPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta) iMetaAttachments.replace(iMeta.url, iMeta)
val markdownImage = "![${alt ?: ""}](${state.result.url})" val markdownImage = "![${alt ?: ""}](${state.result.url})"
message = message.insertUrlAtCursor(markdownImage) message.insertUrlAtCursor(markdownImage)
} }
} }
@@ -560,7 +562,7 @@ class LongFormPostViewModel :
title = TextFieldValue("") title = TextFieldValue("")
summary = TextFieldValue("") summary = TextFieldValue("")
coverImageUrl = "" coverImageUrl = ""
message = TextFieldValue("") message.setTextAndPlaceCursorAtEnd("")
publishedAt = TimeUtils.now() publishedAt = TimeUtils.now()
showPreview = false showPreview = false
@@ -595,11 +597,9 @@ class LongFormPostViewModel :
this.multiOrchestrator?.remove(selected) this.multiOrchestrator?.remove(selected)
} }
override fun updateMessage(newMessage: TextFieldValue) { override fun onMessageChanged() {
message = newMessage
if (message.selection.collapsed) { if (message.selection.collapsed) {
val lastWord = newMessage.currentWord() val lastWord = message.currentWord()
if (lastWord.startsWith("@")) { if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord) userSuggestions?.processCurrentWord(lastWord)
@@ -627,7 +627,7 @@ class LongFormPostViewModel :
userSuggestions?.let { userSuggestions -> userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item) userSuggestions.replaceCurrentWord(message, lastWord, item)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item) forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
@@ -642,7 +642,7 @@ class LongFormPostViewModel :
fun autocompleteWithEmoji(item: EmojiMedia) { fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:" val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset() emojiSuggestions?.reset()
draftTag.newVersion() draftTag.newVersion()
} }
@@ -656,21 +656,21 @@ class LongFormPostViewModel :
} }
} }
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset() emojiSuggestions?.reset()
draftTag.newVersion() draftTag.newVersion()
} }
fun canPost(): Boolean = fun canPost(): Boolean =
title.text.isNotBlank() && title.text.isNotBlank() &&
message.text.isNotBlank() && message.text.toString().isNotBlank() &&
!isUploadingImage && !isUploadingImage &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapRaiser || zapRaiserAmount.value != null) && (!wantsZapRaiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null multiOrchestrator == null
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message.insertUrlAtCursor(newElement)
} }
fun selectImage(uris: ImmutableList<SelectedMedia>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -41,7 +41,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -112,7 +112,8 @@ fun NewProductScreen(
postViewModel.quote(it) postViewModel.quote(it)
} }
message?.ifBlank { null }?.let { message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it)) postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
} }
attachment?.ifBlank { null }?.toUri()?.let { attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -131,7 +132,7 @@ open class NewProductViewModel :
var productImages by mutableStateOf<List<ProductImageMeta>>(emptyList()) var productImages by mutableStateOf<List<ProductImageMeta>>(emptyList())
val iMetaDescription = IMetaAttachments() val iMetaDescription = IMetaAttachments()
override var message by mutableStateOf(TextFieldValue("")) override val message = TextFieldState()
val urlPreviews = PreviewState() val urlPreviews = PreviewState()
@@ -151,10 +152,10 @@ open class NewProductViewModel :
val strippingFailureConfirmation = SuspendableConfirmation() val strippingFailureConfirmation = SuspendableConfirmation()
// Classifieds // Classifieds
var title by mutableStateOf(TextFieldValue("")) val title = TextFieldState()
var price by mutableStateOf(TextFieldValue("")) val price = TextFieldState()
var locationText by mutableStateOf(TextFieldValue("")) val locationText = TextFieldState()
var category by mutableStateOf(TextFieldValue("")) val category = TextFieldState()
var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW) var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW)
// Invoices // Invoices
@@ -226,7 +227,7 @@ open class NewProductViewModel :
open fun quote(quote: Note) { open fun quote(quote: Note) {
val accountViewModel = accountViewModel val accountViewModel = accountViewModel
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser -> quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -246,7 +247,7 @@ open class NewProductViewModel :
wantsForwardZapTo = true wantsForwardZapTo = true
} }
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
private fun loadFromDraft(draft: Note) { private fun loadFromDraft(draft: Note) {
@@ -284,10 +285,10 @@ open class NewProductViewModel :
zapRaiserAmount.value = zapraiser zapRaiserAmount.value = zapraiser
} }
title = TextFieldValue(draftEvent.title() ?: "") title.setTextAndPlaceCursorAtEnd(draftEvent.title() ?: "")
price = TextFieldValue(draftEvent.price()?.amount ?: "") price.setTextAndPlaceCursorAtEnd(draftEvent.price()?.amount ?: "")
category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "") category.setTextAndPlaceCursorAtEnd(draftEvent.categories().firstOrNull() ?: "")
locationText = TextFieldValue(draftEvent.location() ?: "") locationText.setTextAndPlaceCursorAtEnd(draftEvent.location() ?: "")
condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW
val imageSet = draftEvent.images().toMutableSet() val imageSet = draftEvent.images().toMutableSet()
@@ -305,9 +306,9 @@ open class NewProductViewModel :
productImages = productImages + ProductImageMeta(it) productImages = productImages + ProductImageMeta(it)
} }
message = TextFieldValue(draftEvent.content) message.setTextAndPlaceCursorAtEnd(draftEvent.content)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
suspend fun sendPostSync() { suspend fun sendPostSync() {
@@ -323,7 +324,7 @@ open class NewProductViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank()) { if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
val template = createTemplate() ?: return val template = createTemplate() ?: return
@@ -336,7 +337,7 @@ open class NewProductViewModel :
val tagger = val tagger =
NewMessageTagger( NewMessageTagger(
message = message.text, message = message.text.toString(),
dao = accountViewModel, dao = accountViewModel,
) )
tagger.run() tagger.run()
@@ -356,15 +357,15 @@ open class NewProductViewModel :
val template = val template =
ClassifiedsEvent.build( ClassifiedsEvent.build(
title.text, title.text.toString(),
PriceTag(price.text, "SATS", null), PriceTag(price.text.toString(), "SATS", null),
tagger.message, tagger.message,
locationText.text.ifBlank { null }, locationText.text.toString().ifBlank { null },
condition, condition,
) { ) {
productImages.forEach { image(it.url) } 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) quotes(quotes)
geoHash?.let { geohash(it) } geoHash?.let { geohash(it) }
@@ -438,8 +439,8 @@ open class NewProductViewModel :
} else { } else {
iMetaDescription.add(it.result, alt, contentWarningReason) iMetaDescription.add(it.result, alt, contentWarningReason)
message = message.insertUrlAtCursor(it.result.url) message.insertUrlAtCursor(it.result.url)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
} }
} }
@@ -458,7 +459,7 @@ open class NewProductViewModel :
open fun cancel() { open fun cancel() {
draftTag.rotate() draftTag.rotate()
message = TextFieldValue("") message.setTextAndPlaceCursorAtEnd("")
multiOrchestrator = null multiOrchestrator = null
mediaUploadTracker.finishUpload() mediaUploadTracker.finishUpload()
@@ -468,10 +469,10 @@ open class NewProductViewModel :
zapRaiserAmount.value = null zapRaiserAmount.value = null
condition = ConditionTag.CONDITION.USED_LIKE_NEW condition = ConditionTag.CONDITION.USED_LIKE_NEW
locationText = TextFieldValue("") locationText.setTextAndPlaceCursorAtEnd("")
title = TextFieldValue("") title.setTextAndPlaceCursorAtEnd("")
category = TextFieldValue("") category.setTextAndPlaceCursorAtEnd("")
price = TextFieldValue("") price.setTextAndPlaceCursorAtEnd("")
wantsForwardZapTo = false wantsForwardZapTo = false
wantsToMarkAsSensitive = false wantsToMarkAsSensitive = false
@@ -505,9 +506,8 @@ open class NewProductViewModel :
this.multiOrchestrator?.remove(selected) this.multiOrchestrator?.remove(selected)
} }
override fun updateMessage(newMessage: TextFieldValue) { override fun onMessageChanged() {
message = newMessage urlPreviews.update(message.text.toString())
urlPreviews.update(message)
if (message.selection.collapsed) { if (message.selection.collapsed) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
@@ -538,8 +538,8 @@ open class NewProductViewModel :
userSuggestions?.let { userSuggestions -> userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item) userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item) forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
@@ -555,8 +555,8 @@ open class NewProductViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:" val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -572,8 +572,8 @@ open class NewProductViewModel :
} }
} }
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -591,7 +591,7 @@ open class NewProductViewModel :
multiOrchestrator == null multiOrchestrator == null
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message.insertUrlAtCursor(newElement)
} }
fun selectImage(uris: ImmutableList<SelectedMedia>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -620,7 +620,7 @@ open class NewProductViewModel :
override fun updateZapFromText() { override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run() tagger.run()
tagger.pTags?.forEach { taggedUser -> tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) { if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -648,19 +648,11 @@ open class NewProductViewModel :
draftTag.newVersion() draftTag.newVersion()
} }
fun updateTitle(it: TextFieldValue) { fun onTitleChanged() {
title = it
draftTag.newVersion() draftTag.newVersion()
} }
fun updatePrice(it: TextFieldValue) { fun onPriceChanged() {
runCatching {
if (it.text.isEmpty()) {
price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
price = it
}
}
draftTag.newVersion() draftTag.newVersion()
} }
@@ -669,13 +661,12 @@ open class NewProductViewModel :
draftTag.newVersion() draftTag.newVersion()
} }
fun updateCategory(value: TextFieldValue) { fun updateCategory(text: String) {
category = value category.setTextAndPlaceCursorAtEnd(text)
draftTag.newVersion() draftTag.newVersion()
} }
fun updateLocation(it: TextFieldValue) { fun onLocationChanged() {
locationText = it
draftTag.newVersion() draftTag.newVersion()
} }
@@ -44,12 +44,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R 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.TextSpinner
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.TitleExplainer
@@ -109,10 +109,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
) )
ThinPaddingTextField( ThinPaddingTextField(
value = postViewModel.title, state = postViewModel.title,
onValueChange = { onTextChanged = { postViewModel.onTitleChanged() },
postViewModel.updateTitle(it)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text( Text(
@@ -120,8 +118,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
visualTransformation = outputTransformation =
UrlUserTagTransformation( UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary,
), ),
colors = colors =
@@ -146,10 +144,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
ThinPaddingTextField( ThinPaddingTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = postViewModel.price, state = postViewModel.price,
onValueChange = { onTextChanged = { postViewModel.onPriceChanged() },
postViewModel.updatePrice(it)
},
placeholder = { placeholder = {
Text( Text(
text = "1000", text = "1000",
@@ -275,10 +271,10 @@ fun SellProduct(postViewModel: NewProductViewModel) {
categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList()
} }
TextSpinner( TextSpinner(
placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "", placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text.toString() }?.second ?: "",
options = categoryOptions, options = categoryOptions,
onSelect = { onSelect = {
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second)) postViewModel.updateCategory(categoryTypes[it].second)
}, },
modifier = modifier =
Modifier Modifier
@@ -313,10 +309,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
) )
ThinPaddingTextField( ThinPaddingTextField(
value = postViewModel.locationText, state = postViewModel.locationText,
onValueChange = { onTextChanged = { postViewModel.onLocationChanged() },
postViewModel.updateLocation(it)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { placeholder = {
Text( Text(
@@ -324,8 +318,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
visualTransformation = outputTransformation =
UrlUserTagTransformation( UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary,
), ),
colors = colors =
@@ -28,7 +28,8 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
@@ -183,7 +184,7 @@ open class ShortNotePostViewModel :
val iMetaAttachments = IMetaAttachments() val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList()) var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
override var message by mutableStateOf(TextFieldValue("")) override val message = TextFieldState()
val urlPreviews = PreviewState() val urlPreviews = PreviewState()
@@ -367,7 +368,7 @@ open class ShortNotePostViewModel :
multiOrchestrator = null multiOrchestrator = null
quote?.let { quotedNote -> quote?.let { quotedNote ->
message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}") message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}")
quotedNote.author?.let { quotedUser -> quotedNote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != user.pubkeyHex) { if (quotedUser.pubkeyHex != user.pubkeyHex) {
@@ -385,7 +386,7 @@ open class ShortNotePostViewModel :
} }
fork?.let { forkedNoted -> fork?.let { forkedNoted ->
message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "") message.setTextAndPlaceCursorAtEnd(version?.event?.content ?: forkedNoted.event?.content ?: "")
forkedNoted.event?.isSensitiveOrNSFW()?.let { forkedNoted.event?.isSensitiveOrNSFW()?.let {
if (it) wantsToMarkAsSensitive = true if (it) wantsToMarkAsSensitive = true
@@ -436,7 +437,7 @@ open class ShortNotePostViewModel :
} }
} }
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
private fun loadFromDraft(draft: Note) { private fun loadFromDraft(draft: Note) {
@@ -533,11 +534,11 @@ open class ShortNotePostViewModel :
wantsPoll = false wantsPoll = false
wantsZapPoll = false wantsZapPoll = false
message = TextFieldValue(draftEvent.content) message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
private fun loadFromDraft(draftEvent: PollEvent) { private fun loadFromDraft(draftEvent: PollEvent) {
@@ -605,11 +606,11 @@ open class ShortNotePostViewModel :
pollType = draftEvent.pollType() pollType = draftEvent.pollType()
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content) message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
private fun loadFromDraft(draftEvent: ZapPollEvent) { private fun loadFromDraft(draftEvent: ZapPollEvent) {
@@ -679,11 +680,11 @@ open class ShortNotePostViewModel :
zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() } zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() }
zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead() zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content) message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
suspend fun sendPostSync() { suspend fun sendPostSync() {
@@ -750,7 +751,7 @@ open class ShortNotePostViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank()) { if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
val attachments = mutableSetOf<Event>() val attachments = mutableSetOf<Event>()
@@ -811,7 +812,7 @@ open class ShortNotePostViewModel :
val tagger = val tagger =
NewMessageTagger( NewMessageTagger(
message.text, message.text.toString(),
pTags, pTags,
eTags, eTags,
accountViewModel, accountViewModel,
@@ -1000,8 +1001,8 @@ open class ShortNotePostViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let { note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta = val iMeta =
@@ -1024,8 +1025,8 @@ open class ShortNotePostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta) iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url) message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
} }
@@ -1042,7 +1043,7 @@ open class ShortNotePostViewModel :
open fun cancel() { open fun cancel() {
draftTag.rotate() draftTag.rotate()
message = TextFieldValue("") message.setTextAndPlaceCursorAtEnd("")
forkedFromNote = null forkedFromNote = null
@@ -1104,15 +1105,15 @@ open class ShortNotePostViewModel :
} }
open fun addToMessage(it: String) { open fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it)) message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
} }
override fun updateMessage(newMessage: TextFieldValue) { override fun onMessageChanged() {
message = newMessage urlPreviews.update(message.text.toString())
urlPreviews.update(message)
if (message.selection.collapsed) { if (message.selection.collapsed) {
val lastWord = newMessage.currentWord() val lastWord = message.currentWord()
if (lastWord.startsWith("@")) { if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord) userSuggestions?.processCurrentWord(lastWord)
@@ -1140,8 +1141,8 @@ open class ShortNotePostViewModel :
userSuggestions?.let { userSuggestions -> userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item) userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item) forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
@@ -1157,8 +1158,8 @@ open class ShortNotePostViewModel :
open fun autocompleteWithEmoji(item: EmojiMedia) { open fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:" val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -1174,8 +1175,8 @@ open class ShortNotePostViewModel :
} }
} }
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -1201,7 +1202,7 @@ open class ShortNotePostViewModel :
} }
// Regular text/media posts require text // Regular text/media posts require text
return message.text.isNotBlank() && return message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading && !mediaUploadTracker.isUploading &&
!isUploadingVoice && !isUploadingVoice &&
!wantsInvoice && !wantsInvoice &&
@@ -1226,7 +1227,7 @@ open class ShortNotePostViewModel :
} }
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message.insertUrlAtCursor(newElement)
} }
fun selectImage(uris: ImmutableList<SelectedMedia>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -1378,7 +1379,7 @@ open class ShortNotePostViewModel :
override fun updateZapFromText() { override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tagger = val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run() tagger.run()
tagger.pTags?.forEach { taggedUser -> tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == 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 androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog 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.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
@@ -415,8 +415,8 @@ fun SendDirectMessageTo(
) )
ThinPaddingTextField( ThinPaddingTextField(
value = postViewModel.toUsers, state = postViewModel.toUsers,
onValueChange = postViewModel::updateToUsers, onTextChanged = postViewModel::onToUsersChanged,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
@@ -432,8 +432,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
) )
}, },
visualTransformation = outputTransformation =
UrlUserTagTransformation( UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary,
), ),
colors = colors =
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -147,7 +148,7 @@ class NewPublicMessageViewModel :
val iMetaAttachments = IMetaAttachments() val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList()) var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
override var message by mutableStateOf(TextFieldValue("")) override val message = TextFieldState()
val urlPreviews = PreviewState() val urlPreviews = PreviewState()
@@ -160,7 +161,7 @@ class NewPublicMessageViewModel :
var emojiSuggestions: EmojiSuggestionState? = null var emojiSuggestions: EmojiSuggestionState? = null
var toUsers by mutableStateOf(TextFieldValue("")) val toUsers = TextFieldState()
// Images and Videos // Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null) var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
@@ -218,10 +219,9 @@ class NewPublicMessageViewModel :
fun load(users: Set<HexKey>) { fun load(users: Set<HexKey>) {
val userSet = users - account.userProfile().pubkeyHex val userSet = users - account.userProfile().pubkeyHex
toUsers = toUsers.setTextAndPlaceCursorAtEnd(
TextFieldValue( userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, )
)
} }
fun reply(post: Note) { fun reply(post: Note) {
@@ -229,8 +229,8 @@ class NewPublicMessageViewModel :
} }
fun quote(quote: Note) { fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message) urlPreviews.update(message.text.toString())
// creates a split with that author. // creates a split with that author.
val quotedAuthor = quote.author ?: return val quotedAuthor = quote.author ?: return
@@ -306,13 +306,12 @@ class NewPublicMessageViewModel :
val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex
toUsers = toUsers.setTextAndPlaceCursorAtEnd(
TextFieldValue( userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, )
)
message = TextFieldValue(draftEvent.content) message.setTextAndPlaceCursorAtEnd(draftEvent.content)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
iMetaAttachments.addAll(draftEvent.imetas()) iMetaAttachments.addAll(draftEvent.imetas())
} }
@@ -349,7 +348,7 @@ class NewPublicMessageViewModel :
} }
suspend fun sendDraftSync() { suspend fun sendDraftSync() {
if (message.text.isBlank()) { if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else { } else {
val broadcast = mutableSetOf<Event>() val broadcast = mutableSetOf<Event>()
@@ -364,13 +363,14 @@ class NewPublicMessageViewModel :
} }
private suspend fun createTemplate(): EventTemplate<PublicMessageEvent> { 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() toUsersTagger.run()
val messageText = message.text.toString()
val msg = val msg =
replyingTo?.let { replyingTo?.let {
it.toNostrUri() + "\n" + message.text it.toNostrUri() + "\n" + messageText
} ?: message.text } ?: messageText
val tagger = NewMessageTagger(msg, null, null, accountViewModel) val tagger = NewMessageTagger(msg, null, null, accountViewModel)
tagger.run() tagger.run()
@@ -470,8 +470,8 @@ class NewPublicMessageViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let { note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { } else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta = val iMeta =
@@ -494,8 +494,8 @@ class NewPublicMessageViewModel :
iMetaAttachments.replace(iMeta.url, iMeta) iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url) message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} }
} }
@@ -512,8 +512,8 @@ class NewPublicMessageViewModel :
fun cancel() { fun cancel() {
draftTag.rotate() draftTag.rotate()
toUsers = TextFieldValue("") toUsers.setTextAndPlaceCursorAtEnd("")
message = TextFieldValue("") message.setTextAndPlaceCursorAtEnd("")
multiOrchestrator = null multiOrchestrator = null
wantsInvoice = false wantsInvoice = false
@@ -546,15 +546,15 @@ class NewPublicMessageViewModel :
} }
fun addToMessage(it: String) { fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it)) message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
} }
override fun updateMessage(newMessage: TextFieldValue) { override fun onMessageChanged() {
message = newMessage urlPreviews.update(message.text.toString())
urlPreviews.update(newMessage)
if (message.selection.collapsed) { if (message.selection.collapsed) {
val lastWord = newMessage.currentWord() val lastWord = message.currentWord()
if (lastWord.startsWith("@")) { if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord) userSuggestions?.processCurrentWord(lastWord)
@@ -569,11 +569,9 @@ class NewPublicMessageViewModel :
draftTag.newVersion() draftTag.newVersion()
} }
fun updateToUsers(newToUsersValue: TextFieldValue) { fun onToUsersChanged() {
toUsers = newToUsersValue if (toUsers.selection.collapsed) {
val lastWord = toUsers.currentWord()
if (newToUsersValue.selection.collapsed) {
val lastWord = newToUsersValue.currentWord()
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
userSuggestions?.processCurrentWord(lastWord) userSuggestions?.processCurrentWord(lastWord)
} }
@@ -594,14 +592,14 @@ class NewPublicMessageViewModel :
userSuggestions?.let { userSuggestions -> userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord() val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item) userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item) forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("") forwardZapToEditting.value = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.currentWord() val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
} }
userSuggestionsMainMessage = null userSuggestionsMainMessage = null
@@ -614,8 +612,8 @@ class NewPublicMessageViewModel :
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:" val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -631,8 +629,8 @@ class NewPublicMessageViewModel :
} }
} }
message = message.replaceCurrentWord(wordToInsert) message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message) urlPreviews.update(message.text.toString())
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -648,7 +646,7 @@ class NewPublicMessageViewModel :
multiOrchestrator == null multiOrchestrator == null
fun insertAtCursor(newElement: String) { fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement) message.insertUrlAtCursor(newElement)
} }
fun selectImage(uris: ImmutableList<SelectedMedia>) { fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -671,7 +669,7 @@ class NewPublicMessageViewModel :
override fun updateZapFromText() { override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run() tagger.run()
tagger.pTags?.forEach { taggedUser -> tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == 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
}