Compare commits

...

23 Commits

Author SHA1 Message Date
Vitor Pamplona
8e4ee7a618 v1.07.2 2026-03-30 16:46:01 -04:00
Vitor Pamplona
1872a5879e Merge pull request #2042 from greenart7c3/main
Assign `rejected` based on the presence of the "rejected" key in intent extras rather than retrieving its specific boolean value
2026-03-30 16:39:13 -04:00
Vitor Pamplona
a293976501 Merge pull request #2043 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 16:38:44 -04:00
Vitor Pamplona
a1095b4c17 Merge pull request #2044 from vitorpamplona/claude/add-gif-keyboard-support-ahCJX
Add image paste support to message input field
2026-03-30 16:38:14 -04:00
Claude
95d678f62f feat: Add GIF keyboard support to short note post screen
Enable the message field to receive images/GIFs from Android's keyboard
using Compose Foundation's contentReceiver API. When content is received,
it's routed through the existing selectImage flow for upload.

https://claude.ai/code/session_019GfY3MtCPcYn3wqTHCMSNu
2026-03-30 20:33:55 +00:00
Crowdin Bot
b625ac6d2d New Crowdin translations by GitHub Action 2026-03-30 20:26:29 +00:00
Vitor Pamplona
f848ad20d8 Merge pull request #2041 from greenart7c3/fix_external_signer
fix: support external signers wrapped in NostrSignerWithClientTag
2026-03-30 16:24:56 -04:00
greenart7c3
198e049873 Assign rejected based on the presence of the "rejected" key in intent extras rather than retrieving its specific boolean value 2026-03-30 17:18:02 -03:00
greenart7c3
f2d007f9ba fix: support external signers wrapped in NostrSignerWithClientTag
The external signer registration logic was failing when the signer was
wrapped in a `NostrSignerWithClientTag`. Fix by:

1. Updating `ListenToExternalSignerIfNeeded` to unwrap the signer if it's
   a `NostrSignerWithClientTag` containing an `IActivityLauncher`
2. Using the unwrapped launcher for intent registration and response handling
3. Renaming the intent callback to `intentLauncher` to avoid shadowing the `ActivityResultLauncher` variable
2026-03-30 17:09:59 -03:00
Vitor Pamplona
4b9c3f239c Fixes version mismatch 2026-03-30 16:02:55 -04:00
Vitor Pamplona
3ac04cdc63 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-30 15:59:14 -04:00
Vitor Pamplona
f9d231063d Merge pull request #2040 from vitorpamplona/claude/migrate-textfield-state-6ezEZ
Migrate text fields from TextFieldValue to TextFieldState
2026-03-30 15:58:19 -04:00
Vitor Pamplona
4bca74888d Merge pull request #2039 from vitorpamplona/claude/relay-manager-user-search-jivZ2
Replace hex input with user search dialog in relay management
2026-03-30 15:46:03 -04:00
Vitor Pamplona
17ff27bac9 Merge pull request #2038 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 15:00:48 -04:00
Vitor Pamplona
3913384fcf Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-30 15:00:32 -04:00
Vitor Pamplona
b02b8c4ed9 new change logs 2026-03-30 14:57:13 -04:00
Crowdin Bot
ed31b12e3f New Crowdin translations by GitHub Action 2026-03-30 18:50:03 +00:00
Vitor Pamplona
3b63264927 Merge pull request #2036 from Nam0101/contribai/docs/undocumented-identity-based-comparison-i
Docs: Undocumented identity-based comparison in `equalImmutableLists`
2026-03-30 14:48:27 -04:00
Nguyen Van Nam
bc491b154b docs: undocumented identity-based comparison in equalimmutablelists
`equalImmutableLists` is public and uses referential equality (`===`) per element rather than structural equality (`==`). Without documentation, callers may assume normal list equality and get false negatives for value-equal but distinct instances. This is a non-obvious contract and should be documented explicitly to prevent subtle logic bugs.


Affected files: ListUtils.kt

Signed-off-by: Nguyen Van Nam <nam.nv205106@gmail.com>
2026-03-30 22:48:41 +07:00
Claude
4d8583eed9 feat: replace hex input with user search in relay manager allow/ban dialogs
Users can now search by name, npub, or NIP-05 identifier when adding
users to relay allow/ban lists, using the same UserSuggestionState
mechanism as the My List screens. The hex pubkey input was not
user-friendly since most users don't know their hex representation.

https://claude.ai/code/session_01SWReoziLKJKxrbwDWprQYs
2026-03-30 03:43:51 +00:00
Claude
72bc7e0ed5 style: apply linter formatting to TextFieldState migration
https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:13:18 +00:00
Claude
f997586e4b feat: migrate remaining IMessageField implementors to TextFieldState
Completes the TextFieldState migration for ShortNotePostViewModel,
LongFormPostViewModel, and their associated post screens (GeoHash,
Hashtag, ShortNote, LongForm).

https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:11:09 +00:00
Claude
1d208edd33 feat: migrate ThinPaddingTextField to BasicTextField(TextFieldState) for GIF keyboard support
Migrates all new post screens from the old BasicTextField(TextFieldValue)
to the new BasicTextField(TextFieldState) API, which properly sets
EditorInfo.contentMimeTypes to enable GIF keyboard input.

Key changes:
- Add TextFieldState-based ThinPaddingTextField overload (keep old for readonly dropdowns)
- Create UrlUserTagOutputTransformation for the new OutputTransformation API
- Create TextFieldState extension functions (insertUrlAtCursor, replaceCurrentWord, currentWord)
- Update IMessageField interface to use TextFieldState + onMessageChanged()
- Migrate all IMessageField implementors: ChannelNewMessageViewModel,
  ChatNewMessageViewModel, NewProductViewModel, NewPublicMessageViewModel,
  CommentPostViewModel, ShortNotePostViewModel, LongFormPostViewModel
- Update PreviewState to accept String instead of TextFieldValue
- Add TextFieldState overload to UserSuggestionState.replaceCurrentWord()
- Update all post screen call sites

https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:09:38 +00:00
39 changed files with 979 additions and 342 deletions

View File

@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 435
versionName = "1.07.1"
versionCode = 438
versionName = "1.07.2"
vectorDrawables {
useSupportLibrary = true

View File

@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.1` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.2` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.07.1"
quartz = "1.07.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.07.1")
implementation("com.vitorpamplona.quartz:quartz:1.07.2")
}
```

View File

@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.07.1
com.vitorpamplona.quartz:quartz:1.07.2
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.07.1"
quartz = "1.07.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.07.1")
implementation("com.vitorpamplona.quartz:quartz:1.07.2")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.07.1")
implementation("com.vitorpamplona.quartz:quartz:1.07.2")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}

View File

@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.07.1
com.vitorpamplona.quartz:quartz:1.07.2
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.

View File

@@ -1,3 +1,18 @@
<a id="v1.07.2"></a>
# [Release v1.07.2: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.2) - 2026-03-30
- Adds GIF uploads support from Keyboard
- Migrates text fields and @ modifiers to the new Jetpack Compose states
- Fixes Bug that wasn't openning Amber to sign
- Fixes Bug on rejections using old Ambers
- Replaces hex input with user search dialog in relay management (allow/ban user)
<a id="v1.07.1"></a>
# [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30
- New translations
- Fixes BLE's module need for synchronized events.
<a id="v1.07.0"></a>
# [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30

View File

@@ -54,8 +54,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 437
versionName = generateVersionName("1.07.1")
versionCode = 438
versionName = generateVersionName("1.07.2")
buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import kotlin.coroutines.cancellation.CancellationException
class UrlUserTagOutputTransformation(
val color: Color,
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val text = asCharSequence().toString()
// Find all @npub mentions using regex and replace in reverse order
// so that earlier indices remain valid after replacements.
val npubRegex = Regex("@npub1[a-z0-9]{58}")
val matches = npubRegex.findAll(text).toList().reversed()
for (match in matches) {
try {
val key = decodePublicKey(match.value.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val displayName = "@${user.toBestDisplayName()}"
replace(match.range.first, match.range.last + 1, displayName)
// Apply color styling to the replaced display name
addStyle(
SpanStyle(color = color, textDecoration = TextDecoration.None),
match.range.first,
match.range.first + displayName.length,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
// Highlight URLs in remaining text
highlightUrls(color)
}
}
private fun TextFieldBuffer.highlightUrls(color: Color) {
val text = asCharSequence().toString()
val urlPattern = android.util.Patterns.WEB_URL
val matcher = urlPattern.matcher(text)
while (matcher.find()) {
addStyle(
SpanStyle(color = color, textDecoration = TextDecoration.None),
matcher.start(),
matcher.end(),
)
}
}

View File

@@ -20,6 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.content.MediaType
import androidx.compose.foundation.content.ReceiveContentListener
import androidx.compose.foundation.content.TransferableContent
import androidx.compose.foundation.content.consume
import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
@@ -27,6 +35,10 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
@@ -35,8 +47,11 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
@@ -50,6 +65,155 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
// COPIED FROM TEXT FIELD
// The only change is the contentPadding below
// New TextFieldState-based overload for GIF keyboard support
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ThinPaddingTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
onTextChanged: (() -> Unit)? = null,
onContentReceived: ((Uri, String?) -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
contentPadding: PaddingValues =
if (label == null) {
TextFieldDefaults.contentPaddingWithoutLabel(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
)
} else {
TextFieldDefaults.contentPaddingWithLabel(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
)
},
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
if (onTextChanged != null) {
val callback by rememberUpdatedState(onTextChanged)
LaunchedEffect(state) {
snapshotFlow { state.text }
.collect { callback() }
}
}
val textColor =
textStyle.color.takeOrElse {
val focused by interactionSource.collectIsFocusedAsState()
when {
!enabled -> MaterialTheme.colorScheme.placeholderText
isError -> MaterialTheme.colorScheme.onSurface
focused -> MaterialTheme.colorScheme.onSurface
else -> MaterialTheme.colorScheme.onSurface
}
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
val lineLimits =
if (singleLine) {
TextFieldLineLimits.SingleLine
} else {
TextFieldLineLimits.MultiLine(minLines, maxLines)
}
val contentModifier =
if (onContentReceived != null) {
modifier.contentReceiver(
object : ReceiveContentListener {
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
if (!transferableContent.hasMediaType(MediaType.Image)) {
return transferableContent
}
val remaining =
transferableContent.consume { item ->
val uri = item.uri
if (uri != null) {
onContentReceived(
uri,
transferableContent.clipEntry.clipData.description
.getMimeType(0),
)
true
} else {
false
}
}
return remaining
}
},
)
} else {
modifier
}
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
state = state,
modifier =
contentModifier
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = 36.dp,
),
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = keyboardOptions,
lineLimits = lineLimits,
interactionSource = interactionSource,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
decorator = { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = contentPadding,
)
},
)
}
}
// Legacy TextFieldValue-based overload (kept for readonly dropdown fields)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThinPaddingTextField(
@@ -76,7 +240,6 @@ fun ThinPaddingTextField(
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
// new fields
contentPadding: PaddingValues =
if (label == null) {
TextFieldDefaults.contentPaddingWithoutLabel(
@@ -113,6 +276,7 @@ fun ThinPaddingTextField(
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
@Suppress("DEPRECATION")
BasicTextField(
value = value,
modifier =

View File

@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.foundation.text.input.TextFieldState
interface IMessageField {
var message: TextFieldValue
val message: TextFieldState
fun updateMessage(newMessage: TextFieldValue)
fun onMessageChanged()
}

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
import android.net.Uri
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
@@ -39,7 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -51,6 +52,7 @@ fun MessageField(
placeholder: Int,
viewModel: IMessageField,
requestFocus: Boolean = true,
onContentReceived: ((Uri, String?) -> Unit)? = null,
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
@@ -65,8 +67,9 @@ fun MessageField(
}
ThinPaddingTextField(
value = viewModel.message,
onValueChange = viewModel::updateMessage,
state = viewModel.message,
onTextChanged = viewModel::onMessageChanged,
onContentReceived = onContentReceived,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -91,7 +94,7 @@ fun MessageField(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
contentPadding =
TextFieldDefaults.contentPaddingWithoutLabel(

View File

@@ -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)
}
}

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
@@ -199,4 +200,17 @@ class UserSuggestionState(
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
)
}
fun replaceCurrentWord(
state: TextFieldState,
word: String,
item: User,
) {
val wordToInsert = "@${item.pubkeyNpub()}"
state.edit {
val lastWordStart = selection.end - word.length
replace(lastWordStart, selection.end, wordToInsert)
selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
}
}
}

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -147,7 +149,7 @@ open class CommentPostViewModel :
var notifying by mutableStateOf<List<User>?>(null)
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -242,7 +244,7 @@ open class CommentPostViewModel :
}
open fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -262,7 +264,7 @@ open class CommentPostViewModel :
wantsForwardZapTo = true
}
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -322,11 +324,11 @@ open class CommentPostViewModel :
wantsForwardZapTo = true
}
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
suspend fun sendPostSync() {
@@ -359,7 +361,7 @@ open class CommentPostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
@@ -376,7 +378,7 @@ open class CommentPostViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
val tagger =
NewMessageTagger(
message = message.text,
message = message.text.toString(),
dao = accountViewModel,
)
tagger.run()
@@ -525,8 +527,8 @@ open class CommentPostViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message)
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
@@ -549,8 +551,8 @@ open class CommentPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
@@ -576,7 +578,7 @@ open class CommentPostViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
replyingTo = null
externalIdentity = null
@@ -618,9 +620,8 @@ open class CommentPostViewModel :
notifying = notifying?.filter { it != userToRemove }
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(message)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = message.currentWord()
@@ -651,8 +652,8 @@ open class CommentPostViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -668,8 +669,8 @@ open class CommentPostViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -685,8 +686,8 @@ open class CommentPostViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -694,14 +695,14 @@ open class CommentPostViewModel :
}
fun canPost(): Boolean =
message.text.isNotBlank() &&
message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -718,7 +719,7 @@ open class CommentPostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {

View File

@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -46,7 +47,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -125,7 +125,8 @@ fun ReplyCommentPostScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {

View File

@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
@@ -165,7 +166,22 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
@Composable
private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
if (accountViewModel.account.signer is IActivityLauncher) {
val externalSignerLauncher =
when (val signer = accountViewModel.account.signer) {
is IActivityLauncher -> {
signer
}
is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> {
signer.inner as IActivityLauncher
}
else -> {
null
}
}
if (externalSignerLauncher != null) {
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
@@ -173,7 +189,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let {
accountViewModel.runOnIO {
accountViewModel.account.signer.newResponse(it)
externalSignerLauncher.newResponse(it)
}
}
}
@@ -181,7 +197,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
)
DisposableEffect(accountViewModel, accountViewModel.account, launcher) {
val launcher: (Intent) -> Unit = { intent ->
val intentLauncher: (Intent) -> Unit = { intent ->
try {
launcher.launch(intent)
} catch (e: ActivityNotFoundException) {
@@ -193,9 +209,9 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
}
}
accountViewModel.account.signer.registerForegroundLauncher(launcher)
externalSignerLauncher.registerForegroundLauncher(intentLauncher)
onDispose {
accountViewModel.account.signer.unregisterForegroundLauncher(launcher)
externalSignerLauncher.unregisterForegroundLauncher(intentLauncher)
}
}
}

View File

@@ -24,11 +24,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -108,7 +108,8 @@ fun ChatroomView(
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage))
newPostModel.message.setTextAndPlaceCursorAtEnd(draftMessage)
newPostModel.onMessageChanged()
}
}

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -203,7 +205,7 @@ class ChatNewMessageViewModel :
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -215,8 +217,8 @@ class ChatNewMessageViewModel :
var emojiSuggestions: EmojiSuggestionState? = null
var toUsers by mutableStateOf(TextFieldValue(""))
var subject by mutableStateOf(TextFieldValue(""))
val toUsers = TextFieldState()
val subject = TextFieldState()
// Invoices
var canAddInvoice by mutableStateOf(false)
@@ -273,10 +275,9 @@ class ChatNewMessageViewModel :
fun load(room: ChatroomKey) {
this.room.tryEmit(room)
this.toUsers =
TextFieldValue(
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
this.toUsers.setTextAndPlaceCursorAtEnd(
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
}
fun reply(replyNote: Note) {
@@ -290,8 +291,8 @@ class ChatNewMessageViewModel :
}
fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message.text.toString())
// creates a split with that author.
val quotedAuthor = quote.author ?: return
@@ -371,14 +372,13 @@ class ChatNewMessageViewModel :
}
draftEvent.subject()?.let {
subject = TextFieldValue()
subject.setTextAndPlaceCursorAtEnd("")
}
if (draftEvent is NIP17Group) {
toUsers =
TextFieldValue(
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
toUsers.setTextAndPlaceCursorAtEnd(
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
val replyId =
when (draftEvent) {
@@ -392,7 +392,7 @@ class ChatNewMessageViewModel :
}
} else if (draftEvent is PrivateDmEvent) {
val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
toUsers = TextFieldValue("@$recipientNPub")
toUsers.setTextAndPlaceCursorAtEnd("@$recipientNPub")
val replyId = draftEvent.replyTo()
if (replyId != null) {
@@ -400,13 +400,14 @@ class ChatNewMessageViewModel :
}
}
message =
val draftText =
if (draftEvent is PrivateDmEvent) {
TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "")
accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: ""
} else {
TextFieldValue(draftEvent.content)
draftEvent.content
}
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(draftText)
urlPreviews.update(message.text.toString())
iMetaAttachments.addAll(draftEvent.imetas())
}
@@ -421,7 +422,7 @@ class ChatNewMessageViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
innerSendPost(draftTag.current)
@@ -563,11 +564,12 @@ class ChatNewMessageViewModel :
private suspend fun innerSendPost(draftTag: String?) {
val room = room.value ?: return
val urls = findURLs(message.text)
val messageText = message.text.toString()
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val message = message.text
val message = messageText
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
@@ -633,8 +635,8 @@ class ChatNewMessageViewModel :
fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
subject = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
subject.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
@@ -667,15 +669,15 @@ class ChatNewMessageViewModel :
}
fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(newMessage)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -689,11 +691,9 @@ class ChatNewMessageViewModel :
draftTag.newVersion()
}
fun updateToUsers(newToUsersValue: TextFieldValue) {
toUsers = newToUsersValue
if (newToUsersValue.selection.collapsed) {
val lastWord = newToUsersValue.currentWord()
fun onToUsersChanged() {
if (toUsers.selection.collapsed) {
val lastWord = toUsers.currentWord()
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
userSuggestions?.processCurrentWord(lastWord)
}
@@ -706,7 +706,7 @@ class ChatNewMessageViewModel :
fun updateRoomFromUsersInput() {
viewModelScope.launch(Dispatchers.IO) {
delay(300)
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel)
val toUsersTagger = NewMessageTagger(toUsers.text.toString(), null, null, accountViewModel)
toUsersTagger.run()
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
@@ -720,8 +720,7 @@ class ChatNewMessageViewModel :
}
}
fun updateSubject(it: TextFieldValue) {
subject = it
fun onSubjectChanged() {
draftTag.newVersion()
}
@@ -738,14 +737,14 @@ class ChatNewMessageViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
updateRoomFromUsersInput()
}
@@ -759,8 +758,8 @@ class ChatNewMessageViewModel :
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -776,8 +775,8 @@ class ChatNewMessageViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -794,8 +793,8 @@ class ChatNewMessageViewModel :
recipientsMissingDmRelays.value.isEmpty()
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
urlPreviews.update(message)
message.insertUrlAtCursor(newElement)
urlPreviews.update(message.text.toString())
}
override fun onCleared() {
@@ -816,7 +815,7 @@ class ChatNewMessageViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {

View File

@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
@@ -67,7 +68,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -80,7 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -158,7 +158,8 @@ fun NewGroupDMScreen(
LaunchedEffect(postViewModel, accountViewModel) {
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.let {
withContext(Dispatchers.IO) {
@@ -530,8 +531,8 @@ fun SendDirectMessageTo(
)
ThinPaddingTextField(
value = postViewModel.toUsers,
onValueChange = postViewModel::updateToUsers,
state = postViewModel.toUsers,
onTextChanged = postViewModel::onToUsersChanged,
modifier =
Modifier
.weight(1f)
@@ -547,8 +548,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -572,8 +573,8 @@ fun SendDirectMessageTo(
)
ThinPaddingTextField(
value = postViewModel.subject,
onValueChange = { postViewModel.updateSubject(it) },
state = postViewModel.subject,
onTextChanged = { postViewModel.onSubjectChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -581,8 +582,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =

View File

@@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -201,8 +201,8 @@ fun EditField(
accountViewModel: AccountViewModel,
) {
ThinPaddingTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
keyboardOptions = PostKeyboard,
shape = EditFieldBorder,
modifier = Modifier.fillMaxWidth(),
@@ -231,7 +231,7 @@ fun EditField(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
)
}

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -137,7 +139,7 @@ open class ChannelNewMessageViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
val message = TextFieldState()
var urlPreview by mutableStateOf<String?>(null)
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false
@@ -274,7 +276,7 @@ open class ChannelNewMessageViewModel :
}
}
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
@@ -302,7 +304,7 @@ open class ChannelNewMessageViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
@@ -365,14 +367,14 @@ open class ChannelNewMessageViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor(it.toNostrUri())
message.insertUrlAtCursor(it.toNostrUri())
}
urlPreview = findUrlInMessage()
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
message = message.insertUrlAtCursor(upload.result.url)
message.insertUrlAtCursor(upload.result.url)
urlPreview = findUrlInMessage()
}
}
@@ -392,18 +394,19 @@ open class ChannelNewMessageViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
val channel = channel ?: return null
val messageText = message.text.toString()
val tagger =
NewMessageTagger(
message = message.text,
message = messageText,
pTags = listOfNotNull(replyTo.value?.author),
eTags = listOfNotNull(replyTo.value),
dao = accountViewModel,
)
tagger.run()
val urls = findURLs(message.text)
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
val channelRelays = channel.relays()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
@@ -533,7 +536,7 @@ open class ChannelNewMessageViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
@@ -561,18 +564,18 @@ open class ChannelNewMessageViewModel :
emojiSuggestions?.reset()
}
open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text).withScheme.firstOrNull()
open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text.toString()).withScheme.firstOrNull()
open fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
open fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
open fun onMessageChanged() {
urlPreview = findUrlInMessage()
if (newMessage.selection.collapsed) {
val lastWord = newMessage.currentWord()
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -600,7 +603,7 @@ open class ChannelNewMessageViewModel :
userSuggestions?.let {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = it.replaceCurrentWord(message, lastWord, item)
it.replaceCurrentWord(message, lastWord, item)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.addItem(item)
forwardZapToEditting = TextFieldValue("")
@@ -615,7 +618,7 @@ open class ChannelNewMessageViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
@@ -631,7 +634,7 @@ open class ChannelNewMessageViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
@@ -648,7 +651,7 @@ open class ChannelNewMessageViewModel :
uploadState?.multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
@@ -675,7 +678,7 @@ open class ChannelNewMessageViewModel :
fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.items.any { it.key == taggedUser }) {

View File

@@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -114,8 +114,8 @@ fun EditFieldRow(
}
ThinPaddingTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -151,7 +151,7 @@ fun EditFieldRow(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
)
}
}

View File

@@ -62,12 +62,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -293,7 +294,7 @@ private fun MarkdownPostScreenBody(
.padding(Size10dp),
) {
RenderContentAsMarkdown(
content = postViewModel.message.text,
content = postViewModel.message.text.toString(),
tags = EmptyTagList,
canPreview = true,
quotesLeft = 1,
@@ -304,9 +305,9 @@ private fun MarkdownPostScreenBody(
}
} else {
// Markdown editor
OutlinedTextField(
value = postViewModel.message,
onValueChange = postViewModel::updateMessage,
ThinPaddingTextField(
state = postViewModel.message,
onTextChanged = postViewModel::onMessageChanged,
modifier =
Modifier
.fillMaxWidth()
@@ -328,7 +329,7 @@ private fun MarkdownPostScreenBody(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -147,7 +149,7 @@ class LongFormPostViewModel :
var isUploadingCoverImage by mutableStateOf(false)
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
var showPreview by mutableStateOf(false)
@@ -241,7 +243,7 @@ class LongFormPostViewModel :
summary = TextFieldValue(noteEvent.summary() ?: "")
publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt
coverImageUrl = noteEvent.image() ?: ""
message = TextFieldValue(noteEvent.content)
message.setTextAndPlaceCursorAtEnd(noteEvent.content)
existingDTag = noteEvent.dTag()
}
@@ -265,7 +267,7 @@ class LongFormPostViewModel :
summary = TextFieldValue(draftEvent.summary() ?: "")
publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt
coverImageUrl = draftEvent.image() ?: ""
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
existingDTag = draftEvent.dTag()
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
@@ -335,7 +337,7 @@ class LongFormPostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank() && title.text.isBlank()) {
if (message.text.toString().isBlank() && title.text.isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val template = createTemplate() ?: return
@@ -349,7 +351,7 @@ class LongFormPostViewModel :
val tagger =
NewMessageTagger(
message.text,
message.text.toString(),
null,
null,
accountViewModel,
@@ -539,7 +541,7 @@ class LongFormPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
val markdownImage = "![${alt ?: ""}](${state.result.url})"
message = message.insertUrlAtCursor(markdownImage)
message.insertUrlAtCursor(markdownImage)
}
}
@@ -559,7 +561,7 @@ class LongFormPostViewModel :
title = TextFieldValue("")
summary = TextFieldValue("")
coverImageUrl = ""
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
publishedAt = TimeUtils.now()
showPreview = false
@@ -594,11 +596,9 @@ class LongFormPostViewModel :
this.multiOrchestrator?.remove(selected)
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
override fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -626,7 +626,7 @@ class LongFormPostViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
userSuggestions.replaceCurrentWord(message, lastWord, item)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -641,7 +641,7 @@ class LongFormPostViewModel :
fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -655,21 +655,21 @@ class LongFormPostViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
draftTag.newVersion()
}
fun canPost(): Boolean =
title.text.isNotBlank() &&
message.text.isNotBlank() &&
message.text.toString().isNotBlank() &&
!isUploadingImage &&
!wantsInvoice &&
(!wantsZapRaiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -699,7 +699,7 @@ class LongFormPostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {

View File

@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -41,7 +42,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -112,7 +112,8 @@ fun NewProductScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -131,7 +133,7 @@ open class NewProductViewModel :
var productImages by mutableStateOf<List<ProductImageMeta>>(emptyList())
val iMetaDescription = IMetaAttachments()
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -151,10 +153,10 @@ open class NewProductViewModel :
val strippingFailureConfirmation = SuspendableConfirmation()
// Classifieds
var title by mutableStateOf(TextFieldValue(""))
var price by mutableStateOf(TextFieldValue(""))
var locationText by mutableStateOf(TextFieldValue(""))
var category by mutableStateOf(TextFieldValue(""))
val title = TextFieldState()
val price = TextFieldState()
val locationText = TextFieldState()
val category = TextFieldState()
var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW)
// Invoices
@@ -226,7 +228,7 @@ open class NewProductViewModel :
open fun quote(quote: Note) {
val accountViewModel = accountViewModel
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -246,7 +248,7 @@ open class NewProductViewModel :
wantsForwardZapTo = true
}
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -284,10 +286,10 @@ open class NewProductViewModel :
zapRaiserAmount.value = zapraiser
}
title = TextFieldValue(draftEvent.title() ?: "")
price = TextFieldValue(draftEvent.price()?.amount ?: "")
category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "")
locationText = TextFieldValue(draftEvent.location() ?: "")
title.setTextAndPlaceCursorAtEnd(draftEvent.title() ?: "")
price.setTextAndPlaceCursorAtEnd(draftEvent.price()?.amount ?: "")
category.setTextAndPlaceCursorAtEnd(draftEvent.categories().firstOrNull() ?: "")
locationText.setTextAndPlaceCursorAtEnd(draftEvent.location() ?: "")
condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW
val imageSet = draftEvent.images().toMutableSet()
@@ -305,9 +307,9 @@ open class NewProductViewModel :
productImages = productImages + ProductImageMeta(it)
}
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
suspend fun sendPostSync() {
@@ -323,7 +325,7 @@ open class NewProductViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val template = createTemplate() ?: return
@@ -336,7 +338,7 @@ open class NewProductViewModel :
val tagger =
NewMessageTagger(
message = message.text,
message = message.text.toString(),
dao = accountViewModel,
)
tagger.run()
@@ -356,15 +358,15 @@ open class NewProductViewModel :
val template =
ClassifiedsEvent.build(
title.text,
PriceTag(price.text, "SATS", null),
title.text.toString(),
PriceTag(price.text.toString(), "SATS", null),
tagger.message,
locationText.text.ifBlank { null },
locationText.text.toString().ifBlank { null },
condition,
) {
productImages.forEach { image(it.url) }
hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message))
hashtags(listOfNotNull(category.text.toString().ifBlank { null }) + findHashtags(tagger.message))
quotes(quotes)
geoHash?.let { geohash(it) }
@@ -438,8 +440,8 @@ open class NewProductViewModel :
} else {
iMetaDescription.add(it.result, alt, contentWarningReason)
message = message.insertUrlAtCursor(it.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(it.result.url)
urlPreviews.update(message.text.toString())
}
}
}
@@ -458,7 +460,7 @@ open class NewProductViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
multiOrchestrator = null
mediaUploadTracker.finishUpload()
@@ -468,10 +470,10 @@ open class NewProductViewModel :
zapRaiserAmount.value = null
condition = ConditionTag.CONDITION.USED_LIKE_NEW
locationText = TextFieldValue("")
title = TextFieldValue("")
category = TextFieldValue("")
price = TextFieldValue("")
locationText.setTextAndPlaceCursorAtEnd("")
title.setTextAndPlaceCursorAtEnd("")
category.setTextAndPlaceCursorAtEnd("")
price.setTextAndPlaceCursorAtEnd("")
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
@@ -505,9 +507,8 @@ open class NewProductViewModel :
this.multiOrchestrator?.remove(selected)
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(message)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = message.currentWord()
@@ -538,8 +539,8 @@ open class NewProductViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -555,8 +556,8 @@ open class NewProductViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -572,8 +573,8 @@ open class NewProductViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -591,7 +592,7 @@ open class NewProductViewModel :
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -620,7 +621,7 @@ open class NewProductViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
@@ -648,19 +649,11 @@ open class NewProductViewModel :
draftTag.newVersion()
}
fun updateTitle(it: TextFieldValue) {
title = it
fun onTitleChanged() {
draftTag.newVersion()
}
fun updatePrice(it: TextFieldValue) {
runCatching {
if (it.text.isEmpty()) {
price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
price = it
}
}
fun onPriceChanged() {
draftTag.newVersion()
}
@@ -669,13 +662,12 @@ open class NewProductViewModel :
draftTag.newVersion()
}
fun updateCategory(value: TextFieldValue) {
category = value
fun updateCategory(text: String) {
category.setTextAndPlaceCursorAtEnd(text)
draftTag.newVersion()
}
fun updateLocation(it: TextFieldValue) {
locationText = it
fun onLocationChanged() {
draftTag.newVersion()
}

View File

@@ -44,12 +44,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TextFieldValue // kept for readonly dropdown ThinPaddingTextField
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
@@ -109,10 +109,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
)
ThinPaddingTextField(
value = postViewModel.title,
onValueChange = {
postViewModel.updateTitle(it)
},
state = postViewModel.title,
onTextChanged = { postViewModel.onTitleChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -120,8 +118,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -146,10 +144,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
ThinPaddingTextField(
modifier = Modifier.fillMaxWidth(),
value = postViewModel.price,
onValueChange = {
postViewModel.updatePrice(it)
},
state = postViewModel.price,
onTextChanged = { postViewModel.onPriceChanged() },
placeholder = {
Text(
text = "1000",
@@ -275,10 +271,10 @@ fun SellProduct(postViewModel: NewProductViewModel) {
categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList()
}
TextSpinner(
placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "",
placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text.toString() }?.second ?: "",
options = categoryOptions,
onSelect = {
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
postViewModel.updateCategory(categoryTypes[it].second)
},
modifier =
Modifier
@@ -313,10 +309,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
)
ThinPaddingTextField(
value = postViewModel.locationText,
onValueChange = {
postViewModel.updateLocation(it)
},
state = postViewModel.locationText,
onTextChanged = { postViewModel.onLocationChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -324,8 +318,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =

View File

@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -67,7 +67,8 @@ fun GeoHashPostScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {

View File

@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -67,7 +67,8 @@ fun HashtagPostScreen(
postViewModel.quote(it)
}
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {

View File

@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Poll
@@ -54,7 +55,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
@@ -150,7 +150,8 @@ fun ShortNotePostScreen(
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
postViewModel.load(baseReplyTo, quote, fork, version, draft)
message?.ifBlank { null }?.let {
postViewModel.updateMessage(TextFieldValue(it))
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
@@ -322,6 +323,13 @@ private fun NewPostScreenBody(
MessageField(
R.string.what_s_on_your_mind,
postViewModel,
onContentReceived = { uri, mimeType ->
postViewModel.selectImage(
persistentListOf(
SelectedMedia(uri, mimeType),
),
)
},
)
}
}

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -183,7 +185,7 @@ open class ShortNotePostViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -367,7 +369,7 @@ open class ShortNotePostViewModel :
multiOrchestrator = null
quote?.let { quotedNote ->
message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}")
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}")
quotedNote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != user.pubkeyHex) {
@@ -385,7 +387,7 @@ open class ShortNotePostViewModel :
}
fork?.let { forkedNoted ->
message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "")
message.setTextAndPlaceCursorAtEnd(version?.event?.content ?: forkedNoted.event?.content ?: "")
forkedNoted.event?.isSensitiveOrNSFW()?.let {
if (it) wantsToMarkAsSensitive = true
@@ -436,7 +438,7 @@ open class ShortNotePostViewModel :
}
}
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -533,11 +535,11 @@ open class ShortNotePostViewModel :
wantsPoll = false
wantsZapPoll = false
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draftEvent: PollEvent) {
@@ -605,11 +607,11 @@ open class ShortNotePostViewModel :
pollType = draftEvent.pollType()
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draftEvent: ZapPollEvent) {
@@ -679,11 +681,11 @@ open class ShortNotePostViewModel :
zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() }
zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreviews.update(message)
urlPreviews.update(message.text.toString())
}
suspend fun sendPostSync() {
@@ -750,7 +752,7 @@ open class ShortNotePostViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
@@ -811,7 +813,7 @@ open class ShortNotePostViewModel :
val tagger =
NewMessageTagger(
message.text,
message.text.toString(),
pTags,
eTags,
accountViewModel,
@@ -1000,8 +1002,8 @@ open class ShortNotePostViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message)
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
@@ -1024,8 +1026,8 @@ open class ShortNotePostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
@@ -1042,7 +1044,7 @@ open class ShortNotePostViewModel :
open fun cancel() {
draftTag.rotate()
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
forkedFromNote = null
@@ -1104,15 +1106,15 @@ open class ShortNotePostViewModel :
}
open fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(message)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -1140,8 +1142,8 @@ open class ShortNotePostViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -1157,8 +1159,8 @@ open class ShortNotePostViewModel :
open fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -1174,8 +1176,8 @@ open class ShortNotePostViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -1201,7 +1203,7 @@ open class ShortNotePostViewModel :
}
// Regular text/media posts require text
return message.text.isNotBlank() &&
return message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading &&
!isUploadingVoice &&
!wantsInvoice &&
@@ -1226,7 +1228,7 @@ open class ShortNotePostViewModel :
}
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -1378,7 +1380,7 @@ open class ShortNotePostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger =
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {

View File

@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
@@ -415,8 +415,8 @@ fun SendDirectMessageTo(
)
ThinPaddingTextField(
value = postViewModel.toUsers,
onValueChange = postViewModel::updateToUsers,
state = postViewModel.toUsers,
onTextChanged = postViewModel::onToUsersChanged,
modifier =
Modifier
.weight(1f)
@@ -432,8 +432,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
outputTransformation =
UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -147,7 +149,7 @@ class NewPublicMessageViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
override var message by mutableStateOf(TextFieldValue(""))
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -160,7 +162,7 @@ class NewPublicMessageViewModel :
var emojiSuggestions: EmojiSuggestionState? = null
var toUsers by mutableStateOf(TextFieldValue(""))
val toUsers = TextFieldState()
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
@@ -218,10 +220,9 @@ class NewPublicMessageViewModel :
fun load(users: Set<HexKey>) {
val userSet = users - account.userProfile().pubkeyHex
toUsers =
TextFieldValue(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
toUsers.setTextAndPlaceCursorAtEnd(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
}
fun reply(post: Note) {
@@ -229,8 +230,8 @@ class NewPublicMessageViewModel :
}
fun quote(quote: Note) {
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
urlPreviews.update(message.text.toString())
// creates a split with that author.
val quotedAuthor = quote.author ?: return
@@ -306,13 +307,12 @@ class NewPublicMessageViewModel :
val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex
toUsers =
TextFieldValue(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
toUsers.setTextAndPlaceCursorAtEnd(
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
message = TextFieldValue(draftEvent.content)
urlPreviews.update(message)
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
urlPreviews.update(message.text.toString())
iMetaAttachments.addAll(draftEvent.imetas())
}
@@ -349,7 +349,7 @@ class NewPublicMessageViewModel :
}
suspend fun sendDraftSync() {
if (message.text.isBlank()) {
if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val broadcast = mutableSetOf<Event>()
@@ -364,13 +364,14 @@ class NewPublicMessageViewModel :
}
private suspend fun createTemplate(): EventTemplate<PublicMessageEvent> {
val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel)
val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text.toString(), null, null, accountViewModel)
toUsersTagger.run()
val messageText = message.text.toString()
val msg =
replyingTo?.let {
it.toNostrUri() + "\n" + message.text
} ?: message.text
it.toNostrUri() + "\n" + messageText
} ?: messageText
val tagger = NewMessageTagger(msg, null, null, accountViewModel)
tagger.run()
@@ -470,8 +471,8 @@ class NewPublicMessageViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message)
message.insertUrlAtCursor("nostr:" + it.toNEvent())
urlPreviews.update(message.text.toString())
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
@@ -494,8 +495,8 @@ class NewPublicMessageViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
message = message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message)
message.insertUrlAtCursor(state.result.url)
urlPreviews.update(message.text.toString())
}
}
@@ -512,8 +513,8 @@ class NewPublicMessageViewModel :
fun cancel() {
draftTag.rotate()
toUsers = TextFieldValue("")
message = TextFieldValue("")
toUsers.setTextAndPlaceCursorAtEnd("")
message.setTextAndPlaceCursorAtEnd("")
multiOrchestrator = null
wantsInvoice = false
@@ -546,15 +547,15 @@ class NewPublicMessageViewModel :
}
fun addToMessage(it: String) {
updateMessage(TextFieldValue(message.text + " " + it))
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
onMessageChanged()
}
override fun updateMessage(newMessage: TextFieldValue) {
message = newMessage
urlPreviews.update(newMessage)
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = newMessage.currentWord()
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
userSuggestions?.processCurrentWord(lastWord)
@@ -569,11 +570,9 @@ class NewPublicMessageViewModel :
draftTag.newVersion()
}
fun updateToUsers(newToUsersValue: TextFieldValue) {
toUsers = newToUsersValue
if (newToUsersValue.selection.collapsed) {
val lastWord = newToUsersValue.currentWord()
fun onToUsersChanged() {
if (toUsers.selection.collapsed) {
val lastWord = toUsers.currentWord()
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
userSuggestions?.processCurrentWord(lastWord)
}
@@ -594,14 +593,14 @@ class NewPublicMessageViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message)
userSuggestions.replaceCurrentWord(message, lastWord, item)
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.currentWord()
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
}
userSuggestionsMainMessage = null
@@ -614,8 +613,8 @@ class NewPublicMessageViewModel :
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -631,8 +630,8 @@ class NewPublicMessageViewModel :
}
}
message = message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message)
message.replaceCurrentWord(wordToInsert)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -648,7 +647,7 @@ class NewPublicMessageViewModel :
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
@@ -671,7 +670,7 @@ class NewPublicMessageViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {

View File

@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -29,10 +31,13 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Block
@@ -67,28 +72,39 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -96,6 +112,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun RelayManagementScreen(
@@ -398,26 +416,26 @@ private fun PubkeysTab(
}
if (showBanDialog) {
HexInputDialog(
UserSearchDialog(
title = stringResource(R.string.relay_management_ban_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex),
onConfirm = { hex, reason ->
viewModel.banPubkey(hex, reason.ifBlank { null })
onConfirm = { user, reason ->
viewModel.banPubkey(user.pubkeyHex, reason.ifBlank { null })
showBanDialog = false
},
onDismiss = { showBanDialog = false },
accountViewModel = accountViewModel,
)
}
if (showAllowDialog) {
HexInputDialog(
UserSearchDialog(
title = stringResource(R.string.relay_management_allow_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex),
onConfirm = { hex, reason ->
viewModel.allowPubkey(hex, reason.ifBlank { null })
onConfirm = { user, reason ->
viewModel.allowPubkey(user.pubkeyHex, reason.ifBlank { null })
showAllowDialog = false
},
onDismiss = { showAllowDialog = false },
accountViewModel = accountViewModel,
)
}
}
@@ -973,6 +991,187 @@ private fun SettingsField(
}
}
@Composable
private fun UserSearchDialog(
title: String,
onConfirm: (User, String) -> Unit,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
var selectedUser by remember { mutableStateOf<User?>(null) }
var reasonValue by remember { mutableStateOf("") }
UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
userSuggestions.invalidateData()
}
}
launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect {
userSuggestions.invalidateData()
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
val selected = selectedUser
if (selected != null) {
SelectedUserRow(selected, accountViewModel) {
selectedUser = null
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = reasonValue,
onValueChange = { reasonValue = it },
label = { Text(stringResource(R.string.relay_management_reason_optional)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
} else {
var userName by remember { mutableStateOf(TextFieldValue("")) }
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier = Modifier.fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
userSuggestions.processCurrentWord(it.text)
},
singleLine = true,
trailingIcon = {
if (userName.text.isNotEmpty()) {
IconButton(
onClick = {
userName = TextFieldValue("")
userSuggestions.processCurrentWord("")
},
) {
ClearTextIcon()
}
}
},
)
ShowUserSuggestions(
userSuggestions = userSuggestions,
onSelect = { user ->
selectedUser = user
userName = TextFieldValue("")
userSuggestions.processCurrentWord("")
},
accountViewModel = accountViewModel,
)
}
}
},
confirmButton = {
TextButton(
onClick = { selectedUser?.let { onConfirm(it, reasonValue.trim()) } },
enabled = selectedUser != null,
) {
Text(stringResource(R.string.relay_management_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.relay_management_cancel))
}
},
)
}
@Composable
private fun SelectedUserRow(
user: User,
accountViewModel: AccountViewModel,
onClear: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(SmallBorder)
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(user, 40.dp, accountViewModel = accountViewModel, onClick = null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
UsernameDisplay(user, accountViewModel = accountViewModel)
}
IconButton(onClick = onClear) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.relay_management_remove),
modifier = Modifier.size(20.dp),
)
}
}
}
@Composable
private fun ShowUserSuggestions(
userSuggestions: UserSuggestionState,
onSelect: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
val listState = rememberLazyListState()
AnimateOnNewSearch(userSuggestions, listState)
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier = Modifier.heightIn(0.dp, 200.dp),
state = listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = { onSelect(baseUser) })
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, 40.dp, accountViewModel, Modifier, null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
AboutDisplay(baseUser, accountViewModel)
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
@Composable
private fun HexInputDialog(
title: String,

View File

@@ -1267,6 +1267,10 @@
<string name="git_repository">Repozytorium Git: %1$s</string>
<string name="git_web_address">Strona internetowa:</string>
<string name="git_clone_address">Klonuj:</string>
<string name="nsite_title">Statyczna Witryna: %1$s</string>
<string name="nsite_root_site">Strona główna</string>
<string name="nsite_source">Źródło:</string>
<string name="nsite_servers">Serwery:</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Potwierdzenie znacznika czasu</string>
<string name="ots_info_description">Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie.</string>

View File

@@ -226,7 +226,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="copy_public_key_npub_to_the_clipboard">Kopiraj javni ključ (NPub) v odložišče</string>
<string name="send_a_direct_message">Pošlji direktno sporočilo</string>
<string name="edits_the_user_s_metadata">Uredi uporabnikove metapodatke</string>
<string name="follow">Že sledim</string>
<string name="follow">Sledi</string>
<string name="follow_back">Sledi nazaj</string>
<string name="unblock">Deblokiraj</string>
<string name="copy_user_id">Kopiraj uporabnikov ID</string>
@@ -1282,6 +1282,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="git_repository">Git skladišče: %1$s</string>
<string name="git_web_address">Splet:</string>
<string name="git_clone_address">Klon:</string>
<string name="nsite_title">Statična spletna stran: %1$s</string>
<string name="nsite_root_site">Izhodiščno spletišče</string>
<string name="nsite_source">Vir:</string>
<string name="nsite_servers">Strežniki:</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Dokaz časovnega žiga</string>
<string name="ots_info_description">Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas.</string>

View File

@@ -0,0 +1,131 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.compose
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.text.TextRange
import kotlin.math.max
import kotlin.math.min
fun TextFieldState.insertUrlAtCursor(url: String) {
edit {
var toInsert = url.trim()
val selStart = selection.start
val selEnd = selection.end
val text = asCharSequence()
if (selStart > 0 && text[selStart - 1] != ' ' && text[selStart - 1] != '\n') {
toInsert = " $toInsert"
}
// takes the position before adding an empty char after the url
val endOfUrlIndex = selStart + toInsert.length
if (selEnd < length && text[selEnd] != ' ' && text[selEnd] != '\n') {
toInsert = "$toInsert "
}
replace(selStart, selEnd, toInsert)
selection = TextRange(endOfUrlIndex, endOfUrlIndex)
}
}
fun TextFieldState.replaceCurrentWord(wordToInsert: String) {
edit {
val text = asCharSequence()
val selStart = selection.start
val selEnd = selection.end
val lastWordStart = currentWordStartIdx(text, selStart)
val lastWordEnd = currentWordEndIdx(text, selEnd, length)
val cursor = lastWordStart + wordToInsert.length
replace(lastWordStart, lastWordEnd, wordToInsert)
selection = TextRange(cursor, cursor)
}
}
fun TextFieldState.currentWord(): String {
val text = this.text
val selection = this.selection
if (selection.end != selection.start) return ""
val start = currentWordStartIdx(text, selection.start)
val end = currentWordEndIdx(text, selection.end, text.length)
return if (start < end) {
text.subSequence(start, end).toString()
} else {
""
}
}
private fun currentWordStartIdx(
text: CharSequence,
selectionStart: Int,
): Int {
val previousNewLine = text.lastIndexOf('\n', selectionStart - 1)
val previousSpace = text.lastIndexOf(' ', selectionStart - 1)
return max(
previousNewLine,
previousSpace,
) + 1
}
private fun currentWordEndIdx(
text: CharSequence,
selectionEnd: Int,
textLength: Int,
): Int {
val nextNewLine = text.indexOf('\n', selectionEnd)
val nextSpace = text.indexOf(' ', selectionEnd)
if (nextSpace < 0 && nextNewLine < 0) return selectionEnd
if (nextSpace > 0 && nextNewLine > 0) {
return min(
nextNewLine,
nextSpace,
)
}
if (nextSpace > 0) {
return nextSpace
}
return nextNewLine
}
private fun CharSequence.lastIndexOf(
char: Char,
startIndex: Int,
): Int {
for (i in startIndex downTo 0) {
if (this[i] == char) return i
}
return -1
}
private fun CharSequence.indexOf(
char: Char,
startIndex: Int,
): Int {
for (i in startIndex until length) {
if (this[i] == char) return i
}
return -1
}

View File

@@ -22,6 +22,14 @@ package com.vitorpamplona.amethyst.commons.utils
import kotlinx.collections.immutable.ImmutableList
/**
* Compares two [ImmutableList] instances by element identity (`===`), not value equality (`==`).
*
* Returns `true` only when both lists have the same size and each element at the same index
* is the exact same object reference.
*
* Useful for cheap change detection in UI/state pipelines where object identity is meaningful.
*/
fun <T> equalImmutableLists(
list1: ImmutableList<T>,
list2: ImmutableList<T>,

View File

@@ -315,7 +315,7 @@ mavenPublishing {
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
version = "1.07.1",
version = "1.07.2",
)
// Configure publishing to Maven Central

View File

@@ -52,12 +52,7 @@ data class IntentResult(
result = data.getStringExtra("result"),
event = data.getStringExtra("event"),
`package` = data.getStringExtra("package"),
rejected =
if (data.extras?.containsKey("rejected") == true) {
data.getBooleanExtra("rejected", false)
} else {
null
},
rejected = data.extras?.containsKey("rejected"),
)
fun fromJson(json: String): IntentResult = JsonMapperNip55.fromJsonTo<IntentResult>(json)