mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
33 Commits
a3e239d33f
...
claude/mig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
057c2606af | ||
|
|
d67d84e823 | ||
|
|
383bb44921 | ||
|
|
2b9b57073e | ||
|
|
3b4c57ec45 | ||
|
|
7a513f8d6b | ||
|
|
ac24fb97a0 | ||
|
|
5e6c6063cd | ||
|
|
8097ca52c6 | ||
|
|
68c3360c0f | ||
|
|
1f496bf101 | ||
|
|
09b3d3be8a | ||
|
|
77a0a00a1a | ||
|
|
9042075fe3 | ||
|
|
b047769cf3 | ||
|
|
4299d02abf | ||
|
|
daa4b1cf3b | ||
|
|
ba8934b0e8 | ||
|
|
6ec46daa31 | ||
|
|
6b77d2dd49 | ||
|
|
2b4805ca14 | ||
|
|
f00f64fd18 | ||
|
|
44b4c6a789 | ||
|
|
eefb76ba77 | ||
|
|
e72bbdb59d | ||
|
|
92d67f670a | ||
|
|
1a9bcbf3f8 | ||
|
|
62e8b35c1c | ||
|
|
1878d9fd13 | ||
|
|
019d53b77e | ||
|
|
6554ef78be | ||
|
|
6945f8a934 | ||
|
|
7caabd9ea2 |
@@ -44,7 +44,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -66,7 +65,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -82,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
@@ -295,8 +294,10 @@ fun EditPostView(
|
||||
stringRes(id = R.string.lightning_invoice),
|
||||
stringRes(id = R.string.lightning_create_and_add_invoice),
|
||||
onNewInvoice = {
|
||||
postViewModel.message =
|
||||
TextFieldValue(postViewModel.message.text + "\n\n" + it)
|
||||
postViewModel.messageState.edit {
|
||||
append("\n\n$it")
|
||||
placeCursorBeforeCharAt(length)
|
||||
}
|
||||
postViewModel.wantsInvoice = false
|
||||
},
|
||||
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
|
||||
@@ -411,9 +412,8 @@ private fun MessageField(postViewModel: EditPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = { postViewModel.updateMessage(it) },
|
||||
ThinPaddingTextField(
|
||||
state = postViewModel.messageState,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
@@ -442,7 +442,7 @@ private fun MessageField(postViewModel: EditPostViewModel) {
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,16 +21,17 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.updateText
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -73,13 +74,13 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
var editedFromNote: Note? = null
|
||||
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
val subjectState = TextFieldState()
|
||||
|
||||
var iMetaAttachments by mutableStateOf<List<IMetaTag>>(emptyList())
|
||||
var nip95attachments by
|
||||
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
val messageState = TextFieldState()
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
val mediaUploadTracker = MediaUploadTracker()
|
||||
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
|
||||
@@ -113,7 +114,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
|
||||
multiOrchestrator = null
|
||||
|
||||
message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
|
||||
messageState.updateText(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
this.editedFromNote = edit
|
||||
@@ -143,18 +144,18 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
account.sendEdit(
|
||||
message = message.text,
|
||||
message = messageState.text.toString(),
|
||||
originalNote = editedFromNote!!,
|
||||
notify = notify,
|
||||
summary = subject.text.ifBlank { null },
|
||||
summary = subjectState.text.toString().ifBlank { null },
|
||||
extraNotesToBroadcast,
|
||||
)
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
open fun updateSubject(it: TextFieldValue) {
|
||||
subject = it
|
||||
open fun updateSubject(text: String) {
|
||||
subjectState.updateText(text)
|
||||
}
|
||||
|
||||
fun upload(
|
||||
@@ -218,7 +219,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
messageState.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
@@ -242,7 +243,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
message = message.insertUrlAtCursor(state.result.url)
|
||||
messageState.insertUrlAtCursor(state.result.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
}
|
||||
@@ -259,8 +260,8 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
subject = TextFieldValue("")
|
||||
messageState.updateText("")
|
||||
subjectState.updateText("")
|
||||
|
||||
editedFromNote = null
|
||||
|
||||
@@ -275,39 +276,37 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? =
|
||||
message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
messageState.text.toString().split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
|
||||
}
|
||||
}
|
||||
|
||||
open fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
open fun updateMessage(text: String) {
|
||||
messageState.updateText(text)
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
if (it.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
} else {
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions?.reset()
|
||||
}
|
||||
val lastWord = messageState.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
} else {
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions?.reset()
|
||||
}
|
||||
}
|
||||
|
||||
open fun autocompleteWithUser(item: User) {
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
val lastWord = messageState.currentWord()
|
||||
userSuggestions.replaceCurrentWord(messageState, lastWord, item)
|
||||
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions.reset()
|
||||
}
|
||||
}
|
||||
|
||||
fun canPost() = message.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null
|
||||
fun canPost() = messageState.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
|
||||
@@ -21,163 +21,78 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.util.Patterns
|
||||
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.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
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
|
||||
|
||||
data class RangesChanges(
|
||||
val original: TextRange,
|
||||
val modified: TextRange,
|
||||
)
|
||||
private val WORD_BOUNDARY = Regex("[\\s]+")
|
||||
|
||||
class UrlUserTagTransformation(
|
||||
val color: Color,
|
||||
) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText = buildAnnotatedStringWithUrlHighlighting(text, color)
|
||||
}
|
||||
) : OutputTransformation {
|
||||
override fun TextFieldBuffer.transformOutput() {
|
||||
val text = asCharSequence().toString()
|
||||
// Collect replacements and highlights: (start, end, replacement?)
|
||||
val actions = mutableListOf<Triple<Int, Int, String?>>()
|
||||
|
||||
fun buildAnnotatedStringWithUrlHighlighting(
|
||||
text: AnnotatedString,
|
||||
color: Color,
|
||||
): TransformedText {
|
||||
val substitutions = mutableListOf<RangesChanges>()
|
||||
var searchStart = 0
|
||||
for (match in WORD_BOUNDARY.findAll(text)) {
|
||||
val wordStart = searchStart
|
||||
val wordEnd = match.range.first
|
||||
if (wordEnd > wordStart) {
|
||||
processWord(text.substring(wordStart, wordEnd), wordStart, wordEnd, actions)
|
||||
}
|
||||
searchStart = match.range.last + 1
|
||||
}
|
||||
// Last word
|
||||
if (searchStart < text.length) {
|
||||
processWord(text.substring(searchStart), searchStart, text.length, actions)
|
||||
}
|
||||
|
||||
val newText =
|
||||
buildAnnotatedString {
|
||||
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
|
||||
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
|
||||
append(
|
||||
text.text.split('\n').joinToString("\n") { paragraph: String ->
|
||||
paragraph.split(' ').joinToString(" ") { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
|
||||
val startIndex = builderBefore.toString().length
|
||||
|
||||
builderBefore.append(
|
||||
"$keyB32$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
|
||||
val endIndex = startIndex + keyB32.length
|
||||
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
|
||||
builderAfter.append(
|
||||
"$newWord$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, startNew + newWord.length),
|
||||
),
|
||||
)
|
||||
newWord + restOfWord
|
||||
} else if (Patterns.WEB_URL.matcher(word).matches()) {
|
||||
val startIndex = builderBefore.toString().length
|
||||
val endIndex = startIndex + word.length
|
||||
|
||||
val startNew = builderAfter.toString().length
|
||||
val endNew = startNew + word.length
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, endNew),
|
||||
),
|
||||
)
|
||||
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
} else {
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// if it can't parse the key, don't try to change.
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
substitutions.forEach {
|
||||
// Apply in reverse to preserve indices for replacements
|
||||
for ((start, end, replacement) in actions.reversed()) {
|
||||
if (replacement != null) {
|
||||
replace(start, end, replacement)
|
||||
addStyle(
|
||||
style =
|
||||
SpanStyle(
|
||||
color = color,
|
||||
textDecoration = TextDecoration.None,
|
||||
),
|
||||
start = it.modified.start,
|
||||
end = it.modified.end,
|
||||
SpanStyle(color = color, textDecoration = TextDecoration.None),
|
||||
start,
|
||||
start + replacement.length,
|
||||
)
|
||||
} else {
|
||||
addStyle(
|
||||
SpanStyle(color = color, textDecoration = TextDecoration.None),
|
||||
start,
|
||||
end,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val numberOffsetTranslator =
|
||||
object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
val inInsideRange =
|
||||
substitutions.firstOrNull { offset > it.original.start && offset < it.original.end }
|
||||
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange =
|
||||
(offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat())
|
||||
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).toInt()
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
|
||||
|
||||
return if (lastRangeThrough != null) {
|
||||
lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
val inInsideRange =
|
||||
substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end }
|
||||
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange =
|
||||
(offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat())
|
||||
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).toInt()
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
|
||||
|
||||
return if (lastRangeThrough != null) {
|
||||
lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
private fun processWord(
|
||||
word: String,
|
||||
start: Int,
|
||||
end: Int,
|
||||
actions: MutableList<Triple<Int, Int, String?>>,
|
||||
) {
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
val displayName = "@${user.toBestDisplayName()}$restOfWord"
|
||||
actions.add(Triple(start, end, displayName))
|
||||
} else if (Patterns.WEB_URL.matcher(word).matches()) {
|
||||
actions.add(Triple(start, end, null))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
newText,
|
||||
numberOffsetTranslator,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.OutputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldDecorator
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
@@ -42,19 +45,13 @@ import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
// COPIED FROM TEXT FIELD
|
||||
// The only change is the contentPadding below
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThinPaddingTextField(
|
||||
value: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
state: TextFieldState,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
@@ -67,16 +64,12 @@ fun ThinPaddingTextField(
|
||||
suffix: @Composable (() -> Unit)? = null,
|
||||
supportingText: @Composable (() -> Unit)? = null,
|
||||
isError: Boolean = false,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
outputTransformation: OutputTransformation? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
singleLine: Boolean = false,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
shape: Shape = TextFieldDefaults.shape,
|
||||
colors: TextFieldColors = TextFieldDefaults.colors(),
|
||||
// new fields
|
||||
contentPadding: PaddingValues =
|
||||
if (label == null) {
|
||||
TextFieldDefaults.contentPaddingWithoutLabel(
|
||||
@@ -97,12 +90,10 @@ fun ThinPaddingTextField(
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
|
||||
// If color is not provided via the text style, use content color as a default
|
||||
val textColor =
|
||||
textStyle.color.takeOrElse {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
|
||||
// this has changed, but only because of private access on the original
|
||||
when {
|
||||
!enabled -> MaterialTheme.colorScheme.placeholderText
|
||||
isError -> MaterialTheme.colorScheme.onSurface
|
||||
@@ -114,32 +105,26 @@ fun ThinPaddingTextField(
|
||||
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
state = state,
|
||||
modifier =
|
||||
modifier
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
// this has changed
|
||||
minHeight = 36.dp,
|
||||
),
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = mergedTextStyle,
|
||||
// this has changed
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
visualTransformation = visualTransformation,
|
||||
outputTransformation = outputTransformation,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
interactionSource = interactionSource,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines,
|
||||
decorationBox =
|
||||
@Composable { innerTextField ->
|
||||
lineLimits = lineLimits,
|
||||
decorator =
|
||||
TextFieldDecorator { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = value.text,
|
||||
visualTransformation = visualTransformation,
|
||||
value = state.text.toString(),
|
||||
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = placeholder,
|
||||
label = label,
|
||||
@@ -149,12 +134,11 @@ fun ThinPaddingTextField(
|
||||
suffix = suffix,
|
||||
supportingText = supportingText,
|
||||
shape = shape,
|
||||
singleLine = singleLine,
|
||||
singleLine = lineLimits == TextFieldLineLimits.SingleLine,
|
||||
enabled = enabled,
|
||||
isError = isError,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
// this has changed
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -20,10 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
|
||||
interface IMessageField {
|
||||
var message: TextFieldValue
|
||||
|
||||
fun updateMessage(newMessage: TextFieldValue)
|
||||
val messageState: TextFieldState
|
||||
}
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.content.ReceiveContentListener
|
||||
import androidx.compose.foundation.content.TransferableContent
|
||||
import androidx.compose.foundation.content.consume
|
||||
import androidx.compose.foundation.content.contentReceiver
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -46,11 +51,13 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageField(
|
||||
placeholder: Int,
|
||||
viewModel: IMessageField,
|
||||
requestFocus: Boolean = true,
|
||||
onReceiveUri: ((Uri) -> Unit)? = null,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
@@ -64,22 +71,43 @@ fun MessageField(
|
||||
}
|
||||
}
|
||||
|
||||
val baseModifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
|
||||
val modifier =
|
||||
if (onReceiveUri != null) {
|
||||
baseModifier.contentReceiver(
|
||||
object : ReceiveContentListener {
|
||||
override fun onReceive(content: TransferableContent): TransferableContent? =
|
||||
content.consume { item ->
|
||||
val uri = item.uri
|
||||
if (uri != null) {
|
||||
onReceiveUri(uri)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
baseModifier
|
||||
}
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = viewModel.message,
|
||||
onValueChange = viewModel::updateMessage,
|
||||
state = viewModel.messageState,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(placeholder),
|
||||
@@ -91,8 +119,10 @@ fun MessageField(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
outputTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
textStyle =
|
||||
androidx.compose.material3.LocalTextStyle.current
|
||||
.copy(textDirection = TextDirection.Content),
|
||||
contentPadding =
|
||||
TextFieldDefaults.contentPaddingWithoutLabel(
|
||||
start = 10.dp,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.note.creators.messagefield
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.OnReceiveContentListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
private val SUPPORTED_MIME_TYPES =
|
||||
arrayOf(
|
||||
"image/gif",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RichContentTextField(
|
||||
value: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
onReceiveUri: (Uri) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
|
||||
val hintColor = MaterialTheme.colorScheme.placeholderText.toArgb()
|
||||
val density = LocalDensity.current
|
||||
val paddingPx =
|
||||
with(density) {
|
||||
10.dp.roundToPx()
|
||||
}
|
||||
val verticalPaddingPx =
|
||||
with(density) {
|
||||
5.dp.roundToPx()
|
||||
}
|
||||
|
||||
var isUpdatingFromCompose by remember { mutableStateOf(false) }
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
EditText(context).apply {
|
||||
background = null
|
||||
setPadding(paddingPx, verticalPaddingPx, paddingPx, verticalPaddingPx)
|
||||
hint = placeholder
|
||||
setHintTextColor(hintColor)
|
||||
setTextColor(textColor)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
inputType =
|
||||
EditorInfo.TYPE_CLASS_TEXT or
|
||||
EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE or
|
||||
EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES or
|
||||
EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT
|
||||
minHeight = (36 * context.resources.displayMetrics.density).toInt()
|
||||
isFocusableInTouchMode = true
|
||||
|
||||
ViewCompat.setOnReceiveContentListener(
|
||||
this,
|
||||
SUPPORTED_MIME_TYPES,
|
||||
OnReceiveContentListener { _, payload ->
|
||||
val clip = payload.clip
|
||||
var consumed = false
|
||||
for (i in 0 until clip.itemCount) {
|
||||
clip.getItemAt(i).uri?.let { uri ->
|
||||
onReceiveUri(uri)
|
||||
consumed = true
|
||||
}
|
||||
}
|
||||
if (consumed) null else payload
|
||||
},
|
||||
)
|
||||
|
||||
addTextChangedListener(
|
||||
object : TextWatcher {
|
||||
override fun beforeTextChanged(
|
||||
s: CharSequence?,
|
||||
start: Int,
|
||||
count: Int,
|
||||
after: Int,
|
||||
) {}
|
||||
|
||||
override fun onTextChanged(
|
||||
s: CharSequence?,
|
||||
start: Int,
|
||||
before: Int,
|
||||
count: Int,
|
||||
) {}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
if (isUpdatingFromCompose) return
|
||||
val text = s?.toString() ?: ""
|
||||
val selStart = selectionStart.coerceIn(0, text.length)
|
||||
val selEnd = selectionEnd.coerceIn(0, text.length)
|
||||
onValueChange(
|
||||
TextFieldValue(
|
||||
text = text,
|
||||
selection = TextRange(selStart, selEnd),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
update = { editText ->
|
||||
val currentText = editText.text?.toString() ?: ""
|
||||
if (currentText != value.text) {
|
||||
isUpdatingFromCompose = true
|
||||
editText.setText(value.text)
|
||||
val selStart = value.selection.start.coerceIn(0, value.text.length)
|
||||
val selEnd = value.selection.end.coerceIn(0, value.text.length)
|
||||
editText.setSelection(selStart, selEnd)
|
||||
isUpdatingFromCompose = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.previews
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.service.CachedUrlParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -33,22 +32,22 @@ import kotlinx.coroutines.flow.map
|
||||
|
||||
@Stable
|
||||
class PreviewState {
|
||||
var source = MutableStateFlow(TextFieldValue(""))
|
||||
var source = MutableStateFlow("")
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val results =
|
||||
source
|
||||
.debounce(500)
|
||||
.map {
|
||||
CachedUrlParser.parseValidUrls(it.text)
|
||||
CachedUrlParser.parseValidUrls(it)
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun reset() {
|
||||
source.tryEmit(TextFieldValue(""))
|
||||
source.tryEmit("")
|
||||
}
|
||||
|
||||
fun update(text: TextFieldValue) {
|
||||
fun update(text: String) {
|
||||
source.tryEmit(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -199,4 +200,18 @@ class UserSuggestionState(
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
|
||||
)
|
||||
}
|
||||
|
||||
fun replaceCurrentWord(
|
||||
state: TextFieldState,
|
||||
word: String,
|
||||
item: User,
|
||||
) {
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
state.edit {
|
||||
val lastWordStart = selection.end - word.length
|
||||
replace(lastWordStart, selection.end, wordToInsert)
|
||||
val cursor = lastWordStart + wordToInsert.length
|
||||
placeCursorBeforeCharAt(cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -124,9 +125,8 @@ fun ForwardZapTo(
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.forwardZapToEditting.value,
|
||||
onValueChange = { postViewModel.updateZapForwardTo(it) },
|
||||
ThinPaddingTextField(
|
||||
state = postViewModel.forwardZapToEdittingState,
|
||||
label = { Text(text = stringRes(R.string.zap_split_search_and_add_user)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
@@ -135,8 +135,8 @@ fun ForwardZapTo(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
outputTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.zapsplits
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
@Stable
|
||||
interface IZapField {
|
||||
val forwardZapTo: MutableState<SplitBuilder<User>>
|
||||
val forwardZapToEditting: MutableState<TextFieldValue>
|
||||
val forwardZapToEdittingState: TextFieldState
|
||||
|
||||
fun updateZapFromText()
|
||||
|
||||
@@ -36,6 +36,4 @@ interface IZapField {
|
||||
index: Int,
|
||||
sliderValue: Float,
|
||||
)
|
||||
|
||||
fun updateZapForwardTo(newZapForwardTo: TextFieldValue)
|
||||
}
|
||||
|
||||
@@ -1,758 +1 @@
|
||||
/*
|
||||
* 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.note.nip22Comments
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.IZapRaiser
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.IZapField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.SplitBuilder
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.toZapSplitSetup
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.notify
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.alt
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.blurhash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.dims
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.hash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.magnet
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
open class CommentPostViewModel :
|
||||
ViewModel(),
|
||||
ILocationGrabber,
|
||||
IMessageField,
|
||||
IZapField,
|
||||
IZapRaiser,
|
||||
IExpiration {
|
||||
val draftTag = DraftTagState()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
draftTag.versions.collectLatest {
|
||||
// don't save the first
|
||||
if (it > 0) {
|
||||
sendDraftSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lateinit var accountViewModel: AccountViewModel
|
||||
lateinit var account: Account
|
||||
|
||||
var externalIdentity by mutableStateOf<ExternalId?>(null)
|
||||
var replyingTo: Note? by mutableStateOf(null)
|
||||
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
var notifying by mutableStateOf<List<User>?>(null)
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
val mediaUploadTracker = MediaUploadTracker()
|
||||
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
|
||||
val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
// Images and Videos
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
var wantsSecretEmoji by mutableStateOf(false)
|
||||
|
||||
// Forward Zap to
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
var contentWarningDescription by mutableStateOf("")
|
||||
|
||||
// Expiration Date (NIP-40)
|
||||
var wantsExpirationDate by mutableStateOf(false)
|
||||
override var expirationDate by mutableLongStateOf(TimeUtils.oneDayAhead())
|
||||
|
||||
// GeoHash
|
||||
var wantsToAddGeoHash by mutableStateOf(false)
|
||||
var location: StateFlow<LocationState.LocationResult>? = null
|
||||
|
||||
// ZapRaiser
|
||||
var canAddZapRaiser by mutableStateOf(false)
|
||||
var wantsZapraiser by mutableStateOf(false)
|
||||
override val zapRaiserAmount = mutableStateOf<Long?>(null)
|
||||
|
||||
var wantsAnonymousPost by mutableStateOf(false)
|
||||
|
||||
fun lnAddress(): String? = account.userProfile().lnAddress()
|
||||
|
||||
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
|
||||
|
||||
fun user(): User = account.userProfile()
|
||||
|
||||
open fun init(accountVM: AccountViewModel) {
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
this.canAddInvoice = hasLnAddress()
|
||||
this.canAddZapRaiser = hasLnAddress()
|
||||
|
||||
this.userSuggestions?.reset()
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
}
|
||||
|
||||
fun newPostFor(externalIdentity: ExternalId) {
|
||||
this.externalIdentity = externalIdentity
|
||||
}
|
||||
|
||||
fun editFromDraft(draft: Note) {
|
||||
val noteEvent = draft.event
|
||||
val noteAuthor = draft.author
|
||||
|
||||
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
|
||||
val oldTag = (draft.event as? AddressableEvent)?.dTag()
|
||||
if (oldTag != null) {
|
||||
draftTag.set(oldTag)
|
||||
}
|
||||
loadFromDraft(innerNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun reply(post: Note) {
|
||||
this.replyingTo = post
|
||||
this.externalIdentity = (post.event as? CommentEvent)?.scope()
|
||||
}
|
||||
|
||||
open fun quote(quote: Note) {
|
||||
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
|
||||
|
||||
quote.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
if (forwardZapTo.value.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
|
||||
forwardZapTo.value.addItem(quotedUser)
|
||||
}
|
||||
if (forwardZapTo.value.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
|
||||
forwardZapTo.value.addItem(accountViewModel.userProfile())
|
||||
}
|
||||
|
||||
val pos = forwardZapTo.value.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
|
||||
forwardZapTo.value.updatePercentage(pos, 0.9f)
|
||||
}
|
||||
}
|
||||
|
||||
if (!forwardZapTo.value.items.isEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draft: Note) {
|
||||
val draftEvent = draft.event ?: return
|
||||
if (draftEvent !is CommentEvent) return
|
||||
|
||||
loadFromDraft(draftEvent)
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draftEvent: CommentEvent) {
|
||||
this.externalIdentity = draftEvent.scope()
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null
|
||||
multiOrchestrator = null
|
||||
|
||||
val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" }
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
localForwardZapTo.forEach {
|
||||
val user = LocalCache.getOrCreateUser(it[1])
|
||||
val value = it.last().toFloatOrNull() ?: 0f
|
||||
forwardZapTo.value.addItem(user, value)
|
||||
}
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
wantsForwardZapTo = localForwardZapTo.isNotEmpty()
|
||||
|
||||
wantsToMarkAsSensitive = draftEvent.isSensitive()
|
||||
contentWarningDescription = draftEvent.contentWarningReason() ?: ""
|
||||
|
||||
val draftExpiration = draftEvent.expiration()
|
||||
wantsExpirationDate = draftExpiration != null
|
||||
expirationDate = draftExpiration ?: TimeUtils.oneDayAhead()
|
||||
|
||||
val zapraiser = draftEvent.zapraiserAmount()
|
||||
wantsZapraiser = zapraiser != null
|
||||
zapRaiserAmount.value = null
|
||||
if (zapraiser != null) {
|
||||
zapRaiserAmount.value = zapraiser
|
||||
}
|
||||
|
||||
val replyAddress = draftEvent.replyAddress()
|
||||
|
||||
if (replyAddress.isNotEmpty()) {
|
||||
replyingTo = LocalCache.getOrCreateAddressableNote(replyAddress.first())
|
||||
} else {
|
||||
draftEvent.replyingTo()?.let {
|
||||
replyingTo = LocalCache.getOrCreateNote(it)
|
||||
}
|
||||
}
|
||||
|
||||
wantsToAddGeoHash = draftEvent.hasGeohashes()
|
||||
|
||||
notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } +
|
||||
draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
|
||||
if (forwardZapTo.value.items.isNotEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
val template = createTemplate() ?: return
|
||||
val extraNotesToBroadcast = mutableListOf<Event>()
|
||||
|
||||
if (nip95attachments.isNotEmpty()) {
|
||||
val usedImages = template.tags.taggedQuoteIds().toSet()
|
||||
nip95attachments.forEach {
|
||||
if (usedImages.contains(it.second.id)) {
|
||||
extraNotesToBroadcast.add(it.first)
|
||||
extraNotesToBroadcast.add(it.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val version = draftTag.current
|
||||
val anonymous = wantsAnonymousPost
|
||||
cancel()
|
||||
|
||||
if (anonymous) {
|
||||
accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast)
|
||||
} else {
|
||||
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
|
||||
}
|
||||
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(version)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
val attachments = mutableSetOf<Event>()
|
||||
nip95attachments.forEach {
|
||||
attachments.add(it.first)
|
||||
attachments.add(it.second)
|
||||
}
|
||||
|
||||
val template = createTemplate() ?: return
|
||||
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, attachments)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message = message.text,
|
||||
dao = accountViewModel,
|
||||
)
|
||||
tagger.run()
|
||||
|
||||
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
|
||||
|
||||
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
|
||||
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
|
||||
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
|
||||
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
|
||||
|
||||
val replyingTo = replyingTo
|
||||
val replyingToEvent = replyingTo?.event
|
||||
|
||||
val template =
|
||||
if (replyingTo != null) {
|
||||
val eventHint = replyingTo.toEventHint<Event>() ?: return null
|
||||
|
||||
CommentEvent.replyBuilder(
|
||||
msg = tagger.message,
|
||||
replyingTo = eventHint,
|
||||
) {
|
||||
val notifyPTags = tagger.pTags?.let { pTagList -> pTagList.map { it.toPTag() } } ?: emptyList()
|
||||
|
||||
val extraNotificationAuthors =
|
||||
if (replyingToEvent is CommunityDefinitionEvent) {
|
||||
replyingToEvent.moderatorKeys().mapNotNull {
|
||||
if (it != replyingToEvent.pubKey) {
|
||||
accountViewModel.checkGetOrCreateUser(it)?.toPTag()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
notify((notifyPTags + extraNotificationAuthors).distinctBy { it.pubKey })
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
geoHash?.let { geohash(it) }
|
||||
}
|
||||
} else {
|
||||
val externalIdentity = externalIdentity ?: return null
|
||||
CommentEvent.replyExternalIdentity(
|
||||
msg = tagger.message,
|
||||
extId = externalIdentity,
|
||||
) {
|
||||
tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) }
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
|
||||
localZapRaiserAmount?.let { zapraiser(it) }
|
||||
zapReceiver?.let { zapSplits(it) }
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
geoHash?.let { geohash(it) }
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let {
|
||||
EmojiUrlTag(
|
||||
it.code,
|
||||
it.link,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
mediaQuality: Int,
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) = try {
|
||||
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata)
|
||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
|
||||
)
|
||||
}
|
||||
|
||||
fun uploadUnsafe(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
mediaQuality: Int,
|
||||
server: ServerName,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
|
||||
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
alt,
|
||||
contentWarningReason,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
account,
|
||||
context,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
message = message.insertUrlAtCursor(state.result.url)
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages =
|
||||
results.errors
|
||||
.map {
|
||||
stringRes(
|
||||
context,
|
||||
it.errorResource,
|
||||
*it.params,
|
||||
)
|
||||
}.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
|
||||
mediaUploadTracker.finishUpload()
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
message = TextFieldValue("")
|
||||
|
||||
replyingTo = null
|
||||
externalIdentity = null
|
||||
|
||||
multiOrchestrator = null
|
||||
mediaUploadTracker.finishUpload()
|
||||
|
||||
notifying = null
|
||||
|
||||
wantsInvoice = false
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount.value = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
contentWarningDescription = ""
|
||||
wantsToAddGeoHash = false
|
||||
wantsSecretEmoji = false
|
||||
wantsAnonymousPost = false
|
||||
|
||||
forwardZapTo.value = SplitBuilder()
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
|
||||
urlPreviews.reset()
|
||||
|
||||
userSuggestions?.reset()
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
iMetaAttachments.reset()
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
notifying = notifying?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(message)
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
} else {
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions?.reset()
|
||||
}
|
||||
|
||||
emojiSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) {
|
||||
forwardZapToEditting.value = newZapForwardTo
|
||||
if (newZapForwardTo.selection.collapsed) {
|
||||
val lastWord = newZapForwardTo.text
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
}
|
||||
|
||||
open fun autocompleteWithUser(item: User) {
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
}
|
||||
|
||||
userSuggestionsMainMessage = null
|
||||
userSuggestions.reset()
|
||||
}
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = item.link + " "
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
iMetaAttachments.downloadAndPrepare(item.link) {
|
||||
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link)
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
!mediaUploadTracker.isUploading &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount.value != null) &&
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
multiOrchestrator = MultiOrchestrator(uris)
|
||||
}
|
||||
|
||||
override fun updateZapPercentage(
|
||||
index: Int,
|
||||
sliderValue: Float,
|
||||
) {
|
||||
forwardZapTo.value.updatePercentage(index, sliderValue)
|
||||
}
|
||||
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger =
|
||||
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
forwardZapTo.value.addItem(taggedUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateZapRaiserAmount(newAmount: Long?) {
|
||||
zapRaiserAmount.value = newAmount
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun toggleMarkAsSensitive() {
|
||||
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun toggleExpirationDate() {
|
||||
wantsExpirationDate = !wantsExpirationDate
|
||||
if (wantsExpirationDate) {
|
||||
expirationDate = TimeUtils.oneDayAhead()
|
||||
}
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
override fun locationFlow(): StateFlow<LocationState.LocationResult> {
|
||||
if (location == null) {
|
||||
location = locationManager().geohashStateFlow
|
||||
}
|
||||
|
||||
return location!!
|
||||
}
|
||||
|
||||
override fun locationManager(): LocationState = Amethyst.instance.locationManager
|
||||
}
|
||||
placeholder_batch0_2
|
||||
@@ -46,7 +46,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -125,7 +124,7 @@ fun ReplyCommentPostScreen(
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.updateMessage(it)
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
@@ -109,7 +108,7 @@ fun ChatroomView(
|
||||
|
||||
if (draftMessage != null) {
|
||||
LaunchedEffect(key1 = draftMessage) {
|
||||
newPostModel.updateMessage(TextFieldValue(draftMessage))
|
||||
newPostModel.updateMessage(draftMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -107,7 +108,6 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -204,7 +204,8 @@ class ChatNewMessageViewModel :
|
||||
|
||||
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val messageState = TextFieldState()
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -217,7 +218,9 @@ class ChatNewMessageViewModel :
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
var toUsers by mutableStateOf(TextFieldValue(""))
|
||||
val toUsersState = TextFieldState()
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
val subjectState = TextFieldState()
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
@@ -229,6 +232,7 @@ class ChatNewMessageViewModel :
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override val forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override val forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
override val forwardZapToEdittingState = TextFieldState()
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
@@ -671,7 +675,7 @@ class ChatNewMessageViewModel :
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(newMessage)
|
||||
|
||||
|
||||
@@ -530,8 +530,7 @@ fun SendDirectMessageTo(
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.toUsers,
|
||||
onValueChange = postViewModel::updateToUsers,
|
||||
state = postViewModel.toUsersState,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
@@ -547,7 +546,7 @@ fun SendDirectMessageTo(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
outputTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
@@ -572,8 +571,7 @@ fun SendDirectMessageTo(
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.subject,
|
||||
onValueChange = { postViewModel.updateSubject(it) },
|
||||
state = postViewModel.subjectState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -581,7 +579,7 @@ fun SendDirectMessageTo(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
outputTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
|
||||
@@ -202,8 +202,7 @@ fun EditField(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
ThinPaddingTextField(
|
||||
value = channelScreenModel.message,
|
||||
onValueChange = { channelScreenModel.updateMessage(it) },
|
||||
state = channelScreenModel.messageState,
|
||||
keyboardOptions = PostKeyboard,
|
||||
shape = EditFieldBorder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -232,7 +231,7 @@ fun EditField(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -137,6 +138,7 @@ open class ChannelNewMessageViewModel :
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
val messageState = TextFieldState()
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
|
||||
|
||||
@@ -114,8 +114,7 @@ fun EditFieldRow(
|
||||
}
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = channelScreenModel.message,
|
||||
onValueChange = { channelScreenModel.updateMessage(it) },
|
||||
state = channelScreenModel.messageState,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
@@ -132,7 +131,7 @@ fun EditFieldRow(
|
||||
trailingIcon = {
|
||||
ThinSendButton(
|
||||
isActive =
|
||||
channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
|
||||
channelScreenModel.messageState.text.isNotBlank() && !channelScreenModel.isUploadingImage,
|
||||
modifier = EditFieldTrailingIconModifier,
|
||||
) {
|
||||
channelScreenModel.sendPost(onSendNewMessage)
|
||||
@@ -151,7 +150,7 @@ fun EditFieldRow(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -304,9 +305,8 @@ private fun MarkdownPostScreenBody(
|
||||
}
|
||||
} else {
|
||||
// Markdown editor
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = postViewModel::updateMessage,
|
||||
ThinPaddingTextField(
|
||||
state = postViewModel.messageState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -328,7 +328,7 @@ private fun MarkdownPostScreenBody(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
|
||||
|
||||
import android.R.attr.version
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -148,7 +149,8 @@ class LongFormPostViewModel :
|
||||
|
||||
var isUploadingCoverImage by mutableStateOf(false)
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val messageState = TextFieldState()
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
var showPreview by mutableStateOf(false)
|
||||
|
||||
@@ -178,6 +180,7 @@ class LongFormPostViewModel :
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
override val forwardZapToEdittingState = TextFieldState()
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
@@ -595,7 +598,7 @@ class LongFormPostViewModel :
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -131,7 +132,8 @@ open class NewProductViewModel :
|
||||
var productImages by mutableStateOf<List<ProductImageMeta>>(emptyList())
|
||||
val iMetaDescription = IMetaAttachments()
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val messageState = TextFieldState()
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -151,8 +153,11 @@ open class NewProductViewModel :
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Classifieds
|
||||
val titleState = TextFieldState()
|
||||
var title by mutableStateOf(TextFieldValue(""))
|
||||
val priceState = TextFieldState()
|
||||
var price by mutableStateOf(TextFieldValue(""))
|
||||
val locationTextState = TextFieldState()
|
||||
var locationText by mutableStateOf(TextFieldValue(""))
|
||||
var category by mutableStateOf(TextFieldValue(""))
|
||||
var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW)
|
||||
@@ -167,6 +172,7 @@ open class NewProductViewModel :
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
override val forwardZapToEdittingState = TextFieldState()
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
@@ -505,7 +511,7 @@ open class NewProductViewModel :
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(message)
|
||||
|
||||
|
||||
@@ -109,10 +109,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.title,
|
||||
onValueChange = {
|
||||
postViewModel.updateTitle(it)
|
||||
},
|
||||
state = postViewModel.titleState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -120,7 +117,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
outputTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
@@ -146,10 +143,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
|
||||
ThinPaddingTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.price,
|
||||
onValueChange = {
|
||||
postViewModel.updatePrice(it)
|
||||
},
|
||||
state = postViewModel.priceState,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "1000",
|
||||
@@ -160,7 +154,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
singleLine = true,
|
||||
lineLimits = androidx.compose.foundation.text.input.TextFieldLineLimits.SingleLine,
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
@@ -222,11 +216,12 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
.padding(end = 5.dp, bottom = 1.dp),
|
||||
) { currentOption, modifier ->
|
||||
ThinPaddingTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
onValueChange = {},
|
||||
state =
|
||||
androidx.compose.foundation.text.input
|
||||
.TextFieldState(currentOption),
|
||||
readOnly = true,
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
lineLimits = androidx.compose.foundation.text.input.TextFieldLineLimits.SingleLine,
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
@@ -286,11 +281,12 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
.padding(end = 5.dp, bottom = 1.dp),
|
||||
) { currentOption, modifier ->
|
||||
ThinPaddingTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
onValueChange = {},
|
||||
state =
|
||||
androidx.compose.foundation.text.input
|
||||
.TextFieldState(currentOption),
|
||||
readOnly = true,
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
lineLimits = androidx.compose.foundation.text.input.TextFieldLineLimits.SingleLine,
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
@@ -313,10 +309,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.locationText,
|
||||
onValueChange = {
|
||||
postViewModel.updateLocation(it)
|
||||
},
|
||||
state = postViewModel.locationTextState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -324,7 +317,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
outputTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
|
||||
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -67,7 +66,7 @@ fun GeoHashPostScreen(
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.updateMessage(it)
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -67,7 +66,7 @@ fun HashtagPostScreen(
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.updateMessage(it)
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -319,9 +319,14 @@ private fun NewPostScreenBody(
|
||||
)
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
MessageField(
|
||||
R.string.what_s_on_your_mind,
|
||||
postViewModel,
|
||||
onReceiveUri = { uri ->
|
||||
val mediaType = context.contentResolver.getType(uri)
|
||||
postViewModel.selectImage(persistentListOf(SelectedMedia(uri, mediaType)))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -183,7 +184,8 @@ open class ShortNotePostViewModel :
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val messageState = TextFieldState()
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -266,6 +268,7 @@ open class ShortNotePostViewModel :
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
override val forwardZapToEdittingState = TextFieldState()
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
@@ -1107,7 +1110,7 @@ open class ShortNotePostViewModel :
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
open fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(message)
|
||||
|
||||
|
||||
@@ -147,8 +147,6 @@ fun NewPublicMessageScreen(
|
||||
titleRes = R.string.public_message,
|
||||
isActive = postViewModel::canPost,
|
||||
onCancel = {
|
||||
// uses the accountViewModel scope to avoid cancelling this
|
||||
// function when the postViewModel is released
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
@@ -156,8 +154,6 @@ fun NewPublicMessageScreen(
|
||||
nav.popBack()
|
||||
},
|
||||
onPost = {
|
||||
// uses the accountViewModel scope to avoid cancelling this
|
||||
// function when the postViewModel is released
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendPostSync()
|
||||
nav.popBack()
|
||||
@@ -211,7 +207,7 @@ fun PublicMessageScreenContent(
|
||||
}
|
||||
}
|
||||
|
||||
MessageFieldRow(postViewModel, accountViewModel, postViewModel.toUsers.text.isNotBlank())
|
||||
MessageFieldRow(postViewModel, accountViewModel, postViewModel.toUsersState.text.isNotBlank())
|
||||
|
||||
DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav)
|
||||
|
||||
@@ -395,7 +391,7 @@ fun SendDirectMessageTo(
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (postViewModel.toUsers.text.isBlank()) {
|
||||
if (postViewModel.toUsersState.text.isBlank()) {
|
||||
launch {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
@@ -415,8 +411,7 @@ fun SendDirectMessageTo(
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.toUsers,
|
||||
onValueChange = postViewModel::updateToUsers,
|
||||
state = postViewModel.toUsersState,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
@@ -432,7 +427,7 @@ fun SendDirectMessageTo(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
outputTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -147,7 +148,8 @@ class NewPublicMessageViewModel :
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val messageState = TextFieldState()
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -160,6 +162,7 @@ class NewPublicMessageViewModel :
|
||||
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
val toUsersState = TextFieldState()
|
||||
var toUsers by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// Images and Videos
|
||||
@@ -178,6 +181,7 @@ class NewPublicMessageViewModel :
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
override val forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
|
||||
override val forwardZapToEditting = mutableStateOf(TextFieldValue(""))
|
||||
override val forwardZapToEdittingState = TextFieldState()
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
@@ -549,7 +553,7 @@ class NewPublicMessageViewModel :
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(newMessage)
|
||||
|
||||
|
||||
@@ -20,11 +20,91 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.compose
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
// ── TextFieldState extensions (new BasicTextField) ──────────────────────────
|
||||
|
||||
fun TextFieldState.updateText(newText: String) {
|
||||
edit {
|
||||
replace(0, length, newText)
|
||||
placeCursorBeforeCharAt(length)
|
||||
}
|
||||
}
|
||||
|
||||
fun TextFieldState.insertUrlAtCursor(url: String) {
|
||||
edit {
|
||||
var toInsert = url.trim()
|
||||
val text = toString()
|
||||
val selStart = selection.start
|
||||
val selEnd = selection.end
|
||||
|
||||
if (selStart > 0 && text[selStart - 1] != ' ' && text[selStart - 1] != '\n') {
|
||||
toInsert = " $toInsert"
|
||||
}
|
||||
|
||||
val endOfUrlIndex = selStart + toInsert.length
|
||||
|
||||
if (selEnd < text.length && text[selEnd] != ' ' && text[selEnd] != '\n') {
|
||||
toInsert = "$toInsert "
|
||||
}
|
||||
|
||||
replace(selStart, selEnd, toInsert)
|
||||
placeCursorBeforeCharAt(endOfUrlIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun TextFieldState.replaceCurrentWord(wordToInsert: String) {
|
||||
edit {
|
||||
val text = toString()
|
||||
val lastWordStart = currentWordStartIdx(text, selection.start)
|
||||
val lastWordEnd = currentWordEndIdx(text, selection.start, selection.end)
|
||||
val cursor = lastWordStart + wordToInsert.length
|
||||
replace(lastWordStart, lastWordEnd, wordToInsert)
|
||||
placeCursorBeforeCharAt(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
fun TextFieldState.currentWord(): String {
|
||||
var result = ""
|
||||
edit {
|
||||
if (selection.end != selection.start) return@edit
|
||||
val str = toString()
|
||||
val start = currentWordStartIdx(str, selection.start)
|
||||
val end = currentWordEndIdx(str, selection.start, selection.end)
|
||||
result = if (start < end) str.substring(start, end) else ""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun currentWordStartIdx(
|
||||
text: String,
|
||||
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: String,
|
||||
selectionStart: Int,
|
||||
selectionEnd: 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
|
||||
}
|
||||
|
||||
// ── TextFieldValue extensions (legacy) ──────────────────────────────────────
|
||||
|
||||
fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue {
|
||||
var toInsert = url.trim()
|
||||
if (selection.start > 0 && text[selection.start - 1] != ' ' && text[selection.start - 1] != '\n') {
|
||||
|
||||
@@ -102,8 +102,8 @@ class ChatNewMessageState(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMessage(newMessage: TextFieldValue) {
|
||||
_message.value = newMessage
|
||||
fun updateMessage(newMessage: String) {
|
||||
_message.value = TextFieldValue(newMessage)
|
||||
}
|
||||
|
||||
fun updateSubject(newSubject: TextFieldValue) {
|
||||
|
||||
Reference in New Issue
Block a user