diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md
index fc9693e92..e99df4ac3 100644
--- a/.claude/skills/android-expert/SKILL.md
+++ b/.claude/skills/android-expert/SKILL.md
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
- versionCode = 435
- versionName = "1.06.3"
+ versionCode = 438
+ versionName = "1.07.2"
vectorDrawables {
useSupportLibrary = true
diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md
index e0d193b0f..962ac0fe9 100644
--- a/.claude/skills/quartz-integration/SKILL.md
+++ b/.claude/skills/quartz-integration/SKILL.md
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
-**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (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.06.3"
+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.06.3")
+ implementation("com.vitorpamplona.quartz:quartz:1.07.2")
}
```
diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md
index eae28c616..d2c954b3e 100644
--- a/.claude/skills/quartz-integration/references/gradle-setup.md
+++ b/.claude/skills/quartz-integration/references/gradle-setup.md
@@ -3,7 +3,7 @@
## Current version
```
-com.vitorpamplona.quartz:quartz:1.06.3
+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.06.3"
+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.06.3")
+ implementation("com.vitorpamplona.quartz:quartz:1.07.2")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
- implementation("com.vitorpamplona.quartz:quartz:1.06.3")
+ implementation("com.vitorpamplona.quartz:quartz:1.07.2")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md
index 3ebfdd9b7..de372a5a2 100644
--- a/.claude/skills/quartz-kmp.md
+++ b/.claude/skills/quartz-kmp.md
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
-com.vitorpamplona.quartz:quartz:1.06.3
+com.vitorpamplona.quartz:quartz:1.07.2
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a96915bac..f449dddb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,193 @@
+
+# [Release v1.07.2: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.2) - 2026-03-30
+
+- Adds GIF uploads support from Keyboard
+- Migrates text fields and @ modifiers to the new Jetpack Compose states
+- Fixes Bug that wasn't openning Amber to sign
+- Fixes Bug on rejections using old Ambers
+- Replaces hex input with user search dialog in relay management (allow/ban user)
+
+
+# [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30
+
+- New translations
+- Fixes BLE's module need for synchronized events.
+
+
+# [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30
+
+Amethyst:
+- Adds support to Pin Notes
+- Adds support to Polls feed screen
+- Adds support for Requests to Vanish
+- Adds support for Relay management (NIP-86) from Amethyst
+- Adds support for Relay monitor assessments (NIP-66) in the Relay Info screen.
+- Adds support for Relay member information (NIP-43) in the Relay Info screen
+- Adds support for WebBookmarks
+- Adds support for Zap Goals
+
+Quartz:
+- Rewords the NostrClient API for simpler commands.
+- Adds NIP-15: Nostr Marketplace protocol — product listings, stalls, merchant events (#2020)
+- Adds NIP-24: Birthday field support added to UserMetadata (#1979)
+- Adds NIP-29: Relay-based groups — group events, member management, moderation (#2021)
+- Adds NIP-32: Labeling protocol — LabelEvent and tag parsing (#1975)
+- Adds NIP-43: Relay access metadata and membership management with UI screens (#2010)
+- Adds NIP-60: Cashu wallet and spending history event support (#2009)
+- Adds NIP-61: Nutzaps protocol support (#2008)
+- Adds NIP-62: Request to Vanish feature — data deletion with relay compliance testing (#1958)
+- Adds NIP-66: Relay monitor discovery events — UI, filter assembler, subscription composable (#1968)
+- Adds NIP-69: P2P Order Events (kind 38383) (#2019)
+- Adds NIP-75: Zap Goals support with creation and rendering (#1965)
+- Adds NIP-77: Negentropy set reconciliation protocol support (#1955)
+- Adds NIP-7D: Thread events (kind 11) rendering (#2016)
+- Adds NIP-85: Trusted Assertions — assertions for events and addressables (#1981)
+- Adds NIP-86: Relay management UI and client implementation (#1954)
+- Adds NIP-87: Ecash mint discoverability — Cashu and Fedimint event types (#2015)
+- Adds NIP-89: Compliance fixes, PlatformLinkTag parsing, app handler extensions (#2030)
+- Adds NIP-90: All DVM kind event classes from data-vending-machines spec, restructured packages (#2023, #2025)
+- Adds NIP-A4: Add k tag to zap requests and enforce e tag prohibition (#1978)
+- Adds NIP-BE: Bluetooth Low Energy mesh networking / Nostr BLE Communications Protocol (#2022)
+- Adds NIP-C7: Chat messages (kind 9) implementation (#2018)
+- Adds NIP-5A: Static website event rendering (#2017)
+- Adds NIP-51, kind 10001: Pinned notes feature (#1956)
+
+Improvements and Fixes:
+- Show toast instead of dialog on media download success
+- Dynamically adjust preferred Blossom server when list changes
+- Add relay discovery to node master rendering list
+- Add scroll to settings page
+- Solves crashing when multiple relays with the same url are included in the resulting list.
+- Keep screen on during PiP playback and survive screen lock (#1999)
+- Desktop feed loading — missing events, broken profile navigation (#2027)
+- Use getOrCreateNote for reply linking to fix flaky thread test (#2027)
+- Route ReadsScreen following-mode events through cache (#2027)
+- Pin ElectrumX server certs for Samsung One UI 7 / Android 16 compatibility (#1937)
+- Duplicate keys in relay management lazy column — sort pubkeys
+- URL detector — fixes localhost:3030 strings, Japanese character URLs
+- Web Bookmarks floating action button shape (circle) and open graph previews.
+- NIP-86 requests now send Accept and Content-Type headers
+- On DMs, activates decryption for all filetypes that match decryption url with the cipher info, not only binaries
+- Adds a try/finally to subscriptions to make sure they close even in crashes.
+- Protects against crashes when the signer sends an unverifiable payload back to Amethyst
+
+Desktop:
+- Cache-centric architecture for desktop feeds (#1905)
+- Render reposts and quoted notes in feed (#2027)
+
+Performance
+- Faster startup procedures with less loading on the main thread.
+- 20x Faster Rfc3986Normalizer and way less objects being created.
+- Url Detector without using regex
+- Parallelize preference file access at startup for faster launch
+- Remove internal runBlocking calls
+- Lazy loading the memory trimming service
+- Speeding up DrawerContent rendering
+- Eagerly delete intermediate temp files in upload pipeline
+
+Refactoring:
+- Simplify NostrClient API for beginner-friendliness (#1986)
+- Simplify relay API with AutoCloseable and serve() helper (#2031)
+- URL detector performance and readability improvements (#2013)
+- Restructure NIP-90 DVMs to match NIP-88 Polls pattern (#2023)
+- Reorganize NIP-BE into subpackages
+- Move public messages and trusted assertions out of experimental
+
+Migrations & Deprecation Fixes
+
+- Migrate `LocalClipboardManager` to `LocalClipboard` (#1995)
+- Migrate `LocalAutofillTree` to semantics-based Autofill API (#1996)
+- Migrate `TabRow`/`ScrollableTabRow` to Material 3 Secondary variants (#1994)
+- Replace `ContextCompat.startActivity` with `Context.startActivity` (#1993)
+- Suppress deprecation and unchecked cast warnings in Quartz internal code (#1985)
+
+Platform & Build
+
+- Add linuxX64 target and restructure native source sets
+- Add explicit permissions to GitHub Actions workflows
+- Stop Gradle daemons after build steps
+- Increase Android CI build timeout to 45 minutes
+- Update video compression library to latest
+- Update dependencies, add local maven repo for easier library development
+- Remove libsodium files
+- Git hooks installation in worktrees (.git file vs directory)
+- Fixes lack of proxy access to download VLC files in claude's web environment.
+- Log level filtering — LogLevel enum, PlatformLog, lambda overloads for 194+ Log.d() calls (#2005)
+- Parallelize BaseDBTest forEachDB using coroutines
+
+Documentation
+
+- Add CLIENT.md guide for building Nostr clients with Quartz
+- Add RELAY.md guide for building relays with Ktor, NostrServer, SQLite
+
+Contributors
+
+- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z — Primary development
+- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef — Log level filtering, video compression, toast downloads, CI fixes, git worktree fix
+- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c — Desktop cache architecture, desktop bugfixes
+- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 — CI workflow permissions
+- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k — Remove libsodium files
+- **mstrofnone** — ElectrumX cert pinning for Samsung One UI 7
+
+Translations
+
+- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
+- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
+- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz
+- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
+- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
+- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
+- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
+- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
+- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h
+- Russian by Anton Zhao
+
+
+# [Release v1.06.3: Anon Posting](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.3) - 2026-03-24
+
+Improvements to Anon Posting and Reply navigation on DMs
+
+
+# [Release v1.06.2: Multi-choice Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.2) - 2026-03-24
+
+- Add poll type selector for single and multiple choice polls
+- Add drag-to-seek support on the video progress indicator
+- Hide video controls on playback start
+- Recording indicator bar stops recording when tapped, not just the small stop icon.
+- Fixes non http uris in the references tag
+- Makes sure quote tags only happen once in events.
+- Removes the clickable NIP-05 URL in the @ tagging of users
+- Reduces the need to compute naddr to index articles in bookmarks
+- Checks if p tags are user pubkeys when loading drafts.
+- Scroll to the replied message when clicking reply preview in chat
+- Add an anonymous reply with a throwaway keypair
+- Fixes pool rendering when one label is large and the other is small
+- Use uri-reference-kmp in commonMain. Remove platform-specific implementations.
+- Avoids crashing the app in URLs with japanese chars
+- Sorts followers into a set to avoid LazyColumn key conflicts.
+- Clickable relay rows on the profile page
+- Move cache lookups from NavHost route lambdas into screen composables
+- Improvements to the status of attestations. Validity first, then Processing status.
+- Fixes Tor Manager flow value and improves Tor binding lifecycle.
+- Simplifies the report feed in the user profile
+- Clears some inconsistencies in translated strings
+- Fixes client tags
+- Fixes routes for AppDefinition events.
+
+
+# [Release v1.06.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.1) - 2026-03-23
+
+- Improvements to the rendering of Polls.
+- Solves some of the crashes of the concurrent modification exception
+- Fixes URL parsers with Japanese chars
+- Fixes Wallet import from Primal on Poco phones
+- Improves the wording of the Last Seen
+- Fixes for "Cannot disable reuse from root if it was caused by other groups"
+- Fixes comparator to avoid Comparison method violates its general contract!
+- Improves zap-store settings
+
-# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21
+# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2026-03-21
Polls:
- Adds support for creating and rendering NIP-85
@@ -271,13 +459,13 @@ Updated translations:
- Russian by Anton Zhao
-# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
+# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08
- Fixed mixed DMs between logged in users.
- Fixed draft screen click to edit post.
-# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
+# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08
#Amethyst v1.05.0: Bookmark Lists, Voice Notes, and WoT Scores
diff --git a/amethyst/build.gradle b/amethyst/build.gradle
index 24b923d5d..5bbb9f734 100644
--- a/amethyst/build.gradle
+++ b/amethyst/build.gradle
@@ -54,9 +54,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
- versionCode = 435
- versionName = generateVersionName("1.06.3")
- buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
+ versionCode = 438
+ versionName = generateVersionName("1.07.2")
+ buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt
new file mode 100644
index 000000000..40bcb20fc
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.actions
+
+import androidx.compose.foundation.text.input.OutputTransformation
+import androidx.compose.foundation.text.input.TextFieldBuffer
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.style.TextDecoration
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.quartz.nip01Core.core.toHexKey
+import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
+import kotlin.coroutines.cancellation.CancellationException
+
+class UrlUserTagOutputTransformation(
+ val color: Color,
+) : OutputTransformation {
+ override fun TextFieldBuffer.transformOutput() {
+ val text = asCharSequence().toString()
+
+ // Find all @npub mentions using regex and replace in reverse order
+ // so that earlier indices remain valid after replacements.
+ val npubRegex = Regex("@npub1[a-z0-9]{58}")
+ val matches = npubRegex.findAll(text).toList().reversed()
+
+ for (match in matches) {
+ try {
+ val key = decodePublicKey(match.value.removePrefix("@"))
+ val user = LocalCache.getOrCreateUser(key.toHexKey())
+ val displayName = "@${user.toBestDisplayName()}"
+
+ replace(match.range.first, match.range.last + 1, displayName)
+
+ // Apply color styling to the replaced display name
+ 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(),
+ )
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt
index e2a31c967..cc3eb1814 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ThinPaddingTextField.kt
@@ -20,6 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.components
+import android.net.Uri
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.content.MediaType
+import androidx.compose.foundation.content.ReceiveContentListener
+import androidx.compose.foundation.content.TransferableContent
+import androidx.compose.foundation.content.consume
+import androidx.compose.foundation.content.contentReceiver
+import androidx.compose.foundation.content.hasMediaType
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
@@ -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 =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt
index 2b2ea5063..b21c36c09 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/IMessageField.kt
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
-import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.foundation.text.input.TextFieldState
interface IMessageField {
- var message: TextFieldValue
+ val message: TextFieldState
- fun updateMessage(newMessage: TextFieldValue)
+ fun onMessageChanged()
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt
index 63509dca6..36cdecdcd 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
+import android.net.Uri
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
@@ -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(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt
index 9a50d8bf8..09adafb3f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewState.kt
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.note.creators.previews
import androidx.compose.runtime.Stable
-import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.service.CachedUrlParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -33,22 +32,22 @@ import kotlinx.coroutines.flow.map
@Stable
class PreviewState {
- var source = MutableStateFlow(TextFieldValue(""))
+ var source = MutableStateFlow("")
@OptIn(FlowPreview::class)
val results =
source
.debounce(500)
.map {
- CachedUrlParser.parseValidUrls(it.text)
+ CachedUrlParser.parseValidUrls(it)
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
fun reset() {
- source.tryEmit(TextFieldValue(""))
+ source.tryEmit("")
}
- fun update(text: TextFieldValue) {
+ fun update(text: String) {
source.tryEmit(text)
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt
index 4adc385d5..b1da7a1ba 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
+import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
@@ -199,4 +200,17 @@ class UserSuggestionState(
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
)
}
+
+ fun replaceCurrentWord(
+ state: TextFieldState,
+ word: String,
+ item: User,
+ ) {
+ val wordToInsert = "@${item.pubkeyNpub()}"
+ state.edit {
+ val lastWordStart = selection.end - word.length
+ replace(lastWordStart, selection.end, wordToInsert)
+ selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
+ }
+ }
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt
index da55dd466..a488bde7a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt
@@ -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?>(null)
- override var message by mutableStateOf(TextFieldValue(""))
+ override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -242,7 +244,7 @@ open class CommentPostViewModel :
}
open fun quote(quote: Note) {
- message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
+ message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
quote.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
@@ -262,7 +264,7 @@ open class CommentPostViewModel :
wantsForwardZapTo = true
}
- urlPreviews.update(message)
+ urlPreviews.update(message.text.toString())
}
private fun loadFromDraft(draft: Note) {
@@ -322,11 +324,11 @@ open class CommentPostViewModel :
wantsForwardZapTo = true
}
- message = TextFieldValue(draftEvent.content)
+ message.setTextAndPlaceCursorAtEnd(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
- urlPreviews.update(message)
+ urlPreviews.update(message.text.toString())
}
suspend fun sendPostSync() {
@@ -359,7 +361,7 @@ open class CommentPostViewModel :
}
suspend fun sendDraftSync() {
- if (message.text.isBlank()) {
+ if (message.text.toString().isBlank()) {
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
} else {
val attachments = mutableSetOf()
@@ -376,7 +378,7 @@ open class CommentPostViewModel :
private suspend fun createTemplate(): EventTemplate? {
val tagger =
NewMessageTagger(
- message = message.text,
+ message = message.text.toString(),
dao = accountViewModel,
)
tagger.run()
@@ -525,8 +527,8 @@ open class CommentPostViewModel :
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
- message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
- urlPreviews.update(message)
+ message.insertUrlAtCursor("nostr:" + it.toNEvent())
+ urlPreviews.update(message.text.toString())
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
@@ -549,8 +551,8 @@ open class CommentPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
- message = message.insertUrlAtCursor(state.result.url)
- urlPreviews.update(message)
+ message.insertUrlAtCursor(state.result.url)
+ urlPreviews.update(message.text.toString())
}
}
@@ -576,7 +578,7 @@ open class CommentPostViewModel :
open fun cancel() {
draftTag.rotate()
- message = TextFieldValue("")
+ message.setTextAndPlaceCursorAtEnd("")
replyingTo = null
externalIdentity = null
@@ -618,9 +620,8 @@ open class CommentPostViewModel :
notifying = notifying?.filter { it != userToRemove }
}
- override fun updateMessage(newMessage: TextFieldValue) {
- message = newMessage
- urlPreviews.update(message)
+ override fun onMessageChanged() {
+ urlPreviews.update(message.text.toString())
if (message.selection.collapsed) {
val lastWord = message.currentWord()
@@ -651,8 +652,8 @@ open class CommentPostViewModel :
userSuggestions?.let { userSuggestions ->
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.currentWord()
- message = userSuggestions.replaceCurrentWord(message, lastWord, item)
- urlPreviews.update(message)
+ userSuggestions.replaceCurrentWord(message, lastWord, item)
+ urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
@@ -668,8 +669,8 @@ open class CommentPostViewModel :
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
- message = message.replaceCurrentWord(wordToInsert)
- urlPreviews.update(message)
+ message.replaceCurrentWord(wordToInsert)
+ urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -685,8 +686,8 @@ open class CommentPostViewModel :
}
}
- message = message.replaceCurrentWord(wordToInsert)
- urlPreviews.update(message)
+ message.replaceCurrentWord(wordToInsert)
+ urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
@@ -694,14 +695,14 @@ open class CommentPostViewModel :
}
fun canPost(): Boolean =
- message.text.isNotBlank() &&
+ message.text.toString().isNotBlank() &&
!mediaUploadTracker.isUploading &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
fun insertAtCursor(newElement: String) {
- message = message.insertUrlAtCursor(newElement)
+ message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList) {
@@ -718,7 +719,7 @@ open class CommentPostViewModel :
override fun updateZapFromText() {
viewModelScope.launch(Dispatchers.IO) {
val tagger =
- NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
+ NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
tagger.run()
tagger.pTags?.forEach { taggedUser ->
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt
index 3c866565f..e8d4bb98f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -46,7 +47,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.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) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt
index 2fef9419e..7b842cae1 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
+import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
@@ -165,7 +166,22 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
@Composable
private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
- if (accountViewModel.account.signer is IActivityLauncher) {
+ val externalSignerLauncher =
+ when (val signer = accountViewModel.account.signer) {
+ is IActivityLauncher -> {
+ signer
+ }
+
+ is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> {
+ signer.inner as IActivityLauncher
+ }
+
+ else -> {
+ null
+ }
+ }
+
+ if (externalSignerLauncher != null) {
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
@@ -173,7 +189,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let {
accountViewModel.runOnIO {
- accountViewModel.account.signer.newResponse(it)
+ externalSignerLauncher.newResponse(it)
}
}
}
@@ -181,7 +197,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
)
DisposableEffect(accountViewModel, accountViewModel.account, launcher) {
- val launcher: (Intent) -> Unit = { intent ->
+ val intentLauncher: (Intent) -> Unit = { intent ->
try {
launcher.launch(intent)
} catch (e: ActivityNotFoundException) {
@@ -193,9 +209,9 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
}
}
- accountViewModel.account.signer.registerForegroundLauncher(launcher)
+ externalSignerLauncher.registerForegroundLauncher(intentLauncher)
onDispose {
- accountViewModel.account.signer.unregisterForegroundLauncher(launcher)
+ externalSignerLauncher.unregisterForegroundLauncher(intentLauncher)
}
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
index ebf4638a1..5103d1e79 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
@@ -24,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()
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt
index 15d28f635..5b83b81b6 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt
@@ -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>(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 }) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt
index 5d3a7ba1d..a7a84109a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
@@ -67,7 +68,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.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 =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
index ea48b7c13..da9d8ed21 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
@@ -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),
)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt
index 94309697b..317262c9e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt
@@ -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>>(emptyList())
- var message by mutableStateOf(TextFieldValue(""))
+ val message = TextFieldState()
var urlPreview by mutableStateOf(null)
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false
@@ -274,7 +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()
@@ -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? {
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 }) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt
index ddf1ad2d4..1c2669b35 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt
@@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
-import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
+import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -114,8 +114,8 @@ fun EditFieldRow(
}
ThinPaddingTextField(
- value = channelScreenModel.message,
- onValueChange = { channelScreenModel.updateMessage(it) },
+ state = channelScreenModel.message,
+ onTextChanged = { channelScreenModel.onMessageChanged() },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -151,7 +151,7 @@ fun EditFieldRow(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
- visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
+ outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
)
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt
index 30f60121a..fa0793597 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt
@@ -62,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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt
index 6388db9b1..87bf53a07 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt
@@ -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 = ""
- 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) {
@@ -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 }) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt
index ef9287527..e0c13365e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -41,7 +42,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.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) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt
index d0b7fd87c..392024de8 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt
@@ -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>(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) {
@@ -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()
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt
index 590a3872f..c13346b81 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt
@@ -44,12 +44,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.TextFieldValue // kept for readonly dropdown ThinPaddingTextField
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
-import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
+import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
@@ -109,10 +109,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
)
ThinPaddingTextField(
- value = postViewModel.title,
- onValueChange = {
- postViewModel.updateTitle(it)
- },
+ state = postViewModel.title,
+ onTextChanged = { postViewModel.onTitleChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -120,8 +118,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText,
)
},
- visualTransformation =
- UrlUserTagTransformation(
+ outputTransformation =
+ UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
@@ -146,10 +144,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
ThinPaddingTextField(
modifier = Modifier.fillMaxWidth(),
- value = postViewModel.price,
- onValueChange = {
- postViewModel.updatePrice(it)
- },
+ state = postViewModel.price,
+ onTextChanged = { postViewModel.onPriceChanged() },
placeholder = {
Text(
text = "1000",
@@ -275,10 +271,10 @@ fun SellProduct(postViewModel: NewProductViewModel) {
categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList()
}
TextSpinner(
- placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "",
+ placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text.toString() }?.second ?: "",
options = categoryOptions,
onSelect = {
- postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
+ postViewModel.updateCategory(categoryTypes[it].second)
},
modifier =
Modifier
@@ -313,10 +309,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
)
ThinPaddingTextField(
- value = postViewModel.locationText,
- onValueChange = {
- postViewModel.updateLocation(it)
- },
+ state = postViewModel.locationText,
+ onTextChanged = { postViewModel.onLocationChanged() },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -324,8 +318,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
color = MaterialTheme.colorScheme.placeholderText,
)
},
- visualTransformation =
- UrlUserTagTransformation(
+ outputTransformation =
+ UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt
index 7eb071ecc..def8f5dc0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.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) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt
index 46aad80cf..962b13e45 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.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) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt
index 493f000ce..625119771 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt
@@ -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),
+ ),
+ )
+ },
)
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt
index cd6c1729c..d096a1917 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import android.content.Context
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -183,7 +185,7 @@ open class ShortNotePostViewModel :
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf>>(emptyList())
- override var message by mutableStateOf(TextFieldValue(""))
+ override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -367,7 +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()
@@ -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) {
@@ -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 }) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt
index f6ab68d8a..ada38e5e5 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
-import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
+import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
@@ -415,8 +415,8 @@ fun SendDirectMessageTo(
)
ThinPaddingTextField(
- value = postViewModel.toUsers,
- onValueChange = postViewModel::updateToUsers,
+ state = postViewModel.toUsers,
+ onTextChanged = postViewModel::onToUsersChanged,
modifier =
Modifier
.weight(1f)
@@ -432,8 +432,8 @@ fun SendDirectMessageTo(
color = MaterialTheme.colorScheme.placeholderText,
)
},
- visualTransformation =
- UrlUserTagTransformation(
+ outputTransformation =
+ UrlUserTagOutputTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt
index 9c58dd396..09cd8f47e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt
@@ -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>>(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(null)
@@ -218,10 +220,9 @@ class NewPublicMessageViewModel :
fun load(users: Set) {
val userSet = users - account.userProfile().pubkeyHex
- toUsers =
- TextFieldValue(
- userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
- )
+ toUsers.setTextAndPlaceCursorAtEnd(
+ userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
+ )
}
fun reply(post: Note) {
@@ -229,8 +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()
@@ -364,13 +364,14 @@ class NewPublicMessageViewModel :
}
private suspend fun createTemplate(): EventTemplate {
- val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel)
+ val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text.toString(), null, null, accountViewModel)
toUsersTagger.run()
+ val messageText = message.text.toString()
val msg =
replyingTo?.let {
- it.toNostrUri() + "\n" + message.text
- } ?: message.text
+ it.toNostrUri() + "\n" + messageText
+ } ?: messageText
val tagger = NewMessageTagger(msg, null, null, accountViewModel)
tagger.run()
@@ -470,8 +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) {
@@ -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 }) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt
index 5c7827b58..5c6346fdc 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -29,10 +31,13 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Block
@@ -67,28 +72,39 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
+import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever
+import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.note.AboutDisplay
+import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
+import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch
+import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp
+import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -96,6 +112,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
@Composable
fun RelayManagementScreen(
@@ -398,26 +416,26 @@ private fun PubkeysTab(
}
if (showBanDialog) {
- HexInputDialog(
+ UserSearchDialog(
title = stringResource(R.string.relay_management_ban_pubkey),
- label = stringResource(R.string.relay_management_pubkey_hex),
- onConfirm = { hex, reason ->
- viewModel.banPubkey(hex, reason.ifBlank { null })
+ onConfirm = { user, reason ->
+ viewModel.banPubkey(user.pubkeyHex, reason.ifBlank { null })
showBanDialog = false
},
onDismiss = { showBanDialog = false },
+ accountViewModel = accountViewModel,
)
}
if (showAllowDialog) {
- HexInputDialog(
+ UserSearchDialog(
title = stringResource(R.string.relay_management_allow_pubkey),
- label = stringResource(R.string.relay_management_pubkey_hex),
- onConfirm = { hex, reason ->
- viewModel.allowPubkey(hex, reason.ifBlank { null })
+ onConfirm = { user, reason ->
+ viewModel.allowPubkey(user.pubkeyHex, reason.ifBlank { null })
showAllowDialog = false
},
onDismiss = { showAllowDialog = false },
+ accountViewModel = accountViewModel,
)
}
}
@@ -973,6 +991,187 @@ private fun SettingsField(
}
}
+@Composable
+private fun UserSearchDialog(
+ title: String,
+ onConfirm: (User, String) -> Unit,
+ onDismiss: () -> Unit,
+ accountViewModel: AccountViewModel,
+) {
+ val userSuggestions =
+ remember {
+ UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
+ }
+
+ var selectedUser by remember { mutableStateOf(null) }
+ var reasonValue by remember { mutableStateOf("") }
+
+ UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
+
+ LaunchedEffect(Unit) {
+ launch(Dispatchers.IO) {
+ LocalCache.live.newEventBundles.collect {
+ userSuggestions.invalidateData()
+ }
+ }
+ launch(Dispatchers.IO) {
+ LocalCache.live.deletedEventBundles.collect {
+ userSuggestions.invalidateData()
+ }
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ Column {
+ val selected = selectedUser
+ if (selected != null) {
+ SelectedUserRow(selected, accountViewModel) {
+ selectedUser = null
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = reasonValue,
+ onValueChange = { reasonValue = it },
+ label = { Text(stringResource(R.string.relay_management_reason_optional)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+ } else {
+ var userName by remember { mutableStateOf(TextFieldValue("")) }
+
+ OutlinedTextField(
+ label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
+ modifier = Modifier.fillMaxWidth(),
+ value = userName,
+ onValueChange = {
+ userName = it
+ userSuggestions.processCurrentWord(it.text)
+ },
+ singleLine = true,
+ trailingIcon = {
+ if (userName.text.isNotEmpty()) {
+ IconButton(
+ onClick = {
+ userName = TextFieldValue("")
+ userSuggestions.processCurrentWord("")
+ },
+ ) {
+ ClearTextIcon()
+ }
+ }
+ },
+ )
+
+ ShowUserSuggestions(
+ userSuggestions = userSuggestions,
+ onSelect = { user ->
+ selectedUser = user
+ userName = TextFieldValue("")
+ userSuggestions.processCurrentWord("")
+ },
+ accountViewModel = accountViewModel,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { selectedUser?.let { onConfirm(it, reasonValue.trim()) } },
+ enabled = selectedUser != null,
+ ) {
+ Text(stringResource(R.string.relay_management_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.relay_management_cancel))
+ }
+ },
+ )
+}
+
+@Composable
+private fun SelectedUserRow(
+ user: User,
+ accountViewModel: AccountViewModel,
+ onClear: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(SmallBorder)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ClickableUserPicture(user, 40.dp, accountViewModel = accountViewModel, onClick = null)
+ Column(
+ modifier =
+ Modifier
+ .padding(start = 10.dp)
+ .weight(1f),
+ ) {
+ UsernameDisplay(user, accountViewModel = accountViewModel)
+ }
+ IconButton(onClick = onClear) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = stringResource(R.string.relay_management_remove),
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ShowUserSuggestions(
+ userSuggestions: UserSuggestionState,
+ onSelect: (User) -> Unit,
+ accountViewModel: AccountViewModel,
+) {
+ val listState = rememberLazyListState()
+
+ AnimateOnNewSearch(userSuggestions, listState)
+
+ val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
+
+ if (suggestions.isNotEmpty()) {
+ LazyColumn(
+ contentPadding = PaddingValues(top = 10.dp),
+ modifier = Modifier.heightIn(0.dp, 200.dp),
+ state = listState,
+ ) {
+ itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = { onSelect(baseUser) })
+ .padding(vertical = 8.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ClickableUserPicture(baseUser, 40.dp, accountViewModel, Modifier, null)
+ Column(
+ modifier =
+ Modifier
+ .padding(start = 10.dp)
+ .weight(1f),
+ ) {
+ UsernameDisplay(baseUser, accountViewModel = accountViewModel)
+ AboutDisplay(baseUser, accountViewModel)
+ }
+ }
+
+ HorizontalDivider(thickness = DividerThickness)
+ }
+ }
+ }
+}
+
@Composable
private fun HexInputDialog(
title: String,
diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml
index 352cd7cc5..e3bca072b 100644
--- a/amethyst/src/main/res/values-pl-rPL/strings.xml
+++ b/amethyst/src/main/res/values-pl-rPL/strings.xml
@@ -1267,6 +1267,10 @@
Repozytorium Git: %1$s
Strona internetowa:
Klonuj:
+ Statyczna Witryna: %1$s
+ Strona główna
+ Źródło:
+ Serwery:
OTS: %1$s
Potwierdzenie znacznika czasu
Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie.
diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml
index c9b1a3632..f9912f954 100644
--- a/amethyst/src/main/res/values-sl-rSI/strings.xml
+++ b/amethyst/src/main/res/values-sl-rSI/strings.xml
@@ -226,7 +226,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem
Kopiraj javni ključ (NPub) v odložišče
Pošlji direktno sporočilo
Uredi uporabnikove metapodatke
- Že sledim
+ Sledi
Sledi nazaj
Deblokiraj
Kopiraj uporabnikov ID
@@ -1282,6 +1282,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem
Git skladišče: %1$s
Splet:
Klon:
+ Statična spletna stran: %1$s
+ Izhodiščno spletišče
+ Vir:
+ Strežniki:
OTS: %1$s
Dokaz časovnega žiga
Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas.
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt
new file mode 100644
index 000000000..6b74453af
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.compose
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.ui.text.TextRange
+import kotlin.math.max
+import kotlin.math.min
+
+fun TextFieldState.insertUrlAtCursor(url: String) {
+ edit {
+ var toInsert = url.trim()
+ val selStart = selection.start
+ val selEnd = selection.end
+ val text = asCharSequence()
+
+ if (selStart > 0 && text[selStart - 1] != ' ' && text[selStart - 1] != '\n') {
+ toInsert = " $toInsert"
+ }
+
+ // takes the position before adding an empty char after the url
+ val endOfUrlIndex = selStart + toInsert.length
+
+ if (selEnd < length && text[selEnd] != ' ' && text[selEnd] != '\n') {
+ toInsert = "$toInsert "
+ }
+
+ replace(selStart, selEnd, toInsert)
+ selection = TextRange(endOfUrlIndex, endOfUrlIndex)
+ }
+}
+
+fun TextFieldState.replaceCurrentWord(wordToInsert: String) {
+ edit {
+ val text = asCharSequence()
+ val selStart = selection.start
+ val selEnd = selection.end
+ val lastWordStart = currentWordStartIdx(text, selStart)
+ val lastWordEnd = currentWordEndIdx(text, selEnd, length)
+ val cursor = lastWordStart + wordToInsert.length
+ replace(lastWordStart, lastWordEnd, wordToInsert)
+ selection = TextRange(cursor, cursor)
+ }
+}
+
+fun TextFieldState.currentWord(): String {
+ val text = this.text
+ val selection = this.selection
+ if (selection.end != selection.start) return ""
+
+ val start = currentWordStartIdx(text, selection.start)
+ val end = currentWordEndIdx(text, selection.end, text.length)
+
+ return if (start < end) {
+ text.subSequence(start, end).toString()
+ } else {
+ ""
+ }
+}
+
+private fun currentWordStartIdx(
+ text: CharSequence,
+ selectionStart: Int,
+): Int {
+ val previousNewLine = text.lastIndexOf('\n', selectionStart - 1)
+ val previousSpace = text.lastIndexOf(' ', selectionStart - 1)
+
+ return max(
+ previousNewLine,
+ previousSpace,
+ ) + 1
+}
+
+private fun currentWordEndIdx(
+ text: CharSequence,
+ selectionEnd: Int,
+ textLength: Int,
+): Int {
+ val nextNewLine = text.indexOf('\n', selectionEnd)
+ val nextSpace = text.indexOf(' ', selectionEnd)
+
+ if (nextSpace < 0 && nextNewLine < 0) return selectionEnd
+ if (nextSpace > 0 && nextNewLine > 0) {
+ return min(
+ nextNewLine,
+ nextSpace,
+ )
+ }
+ if (nextSpace > 0) {
+ return nextSpace
+ }
+ return nextNewLine
+}
+
+private fun CharSequence.lastIndexOf(
+ char: Char,
+ startIndex: Int,
+): Int {
+ for (i in startIndex downTo 0) {
+ if (this[i] == char) return i
+ }
+ return -1
+}
+
+private fun CharSequence.indexOf(
+ char: Char,
+ startIndex: Int,
+): Int {
+ for (i in startIndex until length) {
+ if (this[i] == char) return i
+ }
+ return -1
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt
index d70e2417f..8c1412096 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/utils/ListUtils.kt
@@ -22,6 +22,14 @@ package com.vitorpamplona.amethyst.commons.utils
import kotlinx.collections.immutable.ImmutableList
+/**
+ * Compares two [ImmutableList] instances by element identity (`===`), not value equality (`==`).
+ *
+ * Returns `true` only when both lists have the same size and each element at the same index
+ * is the exact same object reference.
+ *
+ * Useful for cheap change detection in UI/state pipelines where object identity is meaningful.
+ */
fun equalImmutableLists(
list1: ImmutableList,
list2: ImmutableList,
diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts
index adb968e98..0008b4376 100644
--- a/quartz/build.gradle.kts
+++ b/quartz/build.gradle.kts
@@ -120,7 +120,7 @@ kotlin {
// SQLite KMP driver for event store
api(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.bundled)
-
+
// Negentropy set reconciliation (NIP-77)
api(libs.negentropy.kmp)
}
@@ -315,7 +315,7 @@ mavenPublishing {
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
- version = "1.06.3",
+ version = "1.07.2",
)
// Configure publishing to Maven Central
diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt
index e6b233964..3cb6635d6 100644
--- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt
+++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/results/IntentResult.kt
@@ -52,12 +52,7 @@ data class IntentResult(
result = data.getStringExtra("result"),
event = data.getStringExtra("event"),
`package` = data.getStringExtra("package"),
- rejected =
- if (data.extras?.containsKey("rejected") == true) {
- data.getBooleanExtra("rejected", false)
- } else {
- null
- },
+ rejected = data.extras?.containsKey("rejected"),
)
fun fromJson(json: String): IntentResult = JsonMapperNip55.fromJsonTo(json)
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt
index dfd154708..c27f465f5 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssembler.kt
@@ -20,37 +20,43 @@
*/
package com.vitorpamplona.quartz.nipBEBle.protocol
+import kotlin.concurrent.atomics.AtomicReference
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+
/**
* Accumulates incoming BLE chunks for a single message and reassembles
* them once all chunks have arrived.
*
- * Thread-safe: all access to internal state is synchronized.
+ * Thread-safe: uses a lock-free compare-and-set loop over an immutable list.
*/
+@OptIn(ExperimentalAtomicApi::class)
class BleChunkAssembler {
- private val lock = Any()
- private val receivedChunks = mutableListOf()
+ private val receivedChunks = AtomicReference(emptyList())
/**
* Adds a received chunk. If this completes the message, returns the
* reassembled JSON string. Otherwise returns null.
*/
- fun addChunk(chunk: ByteArray): String? =
- synchronized(lock) {
- receivedChunks.add(chunk)
- if (BleMessageChunker.isComplete(receivedChunks)) {
- val message = BleMessageChunker.joinChunks(receivedChunks.toTypedArray())
- receivedChunks.clear()
- message
+ fun addChunk(chunk: ByteArray): String? {
+ while (true) {
+ val current = receivedChunks.load()
+ val updated = current + chunk
+ if (BleMessageChunker.isComplete(updated)) {
+ if (receivedChunks.compareAndSet(current, emptyList())) {
+ return BleMessageChunker.joinChunks(updated.toTypedArray())
+ }
} else {
- null
+ if (receivedChunks.compareAndSet(current, updated)) {
+ return null
+ }
}
}
+ }
/**
* Discards any partially received chunks.
*/
- fun reset() =
- synchronized(lock) {
- receivedChunks.clear()
- }
+ fun reset() {
+ receivedChunks.store(emptyList())
+ }
}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt
index 371b701d7..2c75d9576 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt
@@ -31,6 +31,10 @@ import com.vitorpamplona.quartz.nipBEBle.protocol.BleChunkAssembler
import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport
import com.vitorpamplona.quartz.utils.Log
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlin.concurrent.atomics.AtomicBoolean
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* A Nostr relay client that communicates over BLE instead of WebSockets.
@@ -54,6 +58,7 @@ import com.vitorpamplona.quartz.utils.Log
* client.sendIfConnected(EventCmd(myEvent))
* ```
*/
+@OptIn(ExperimentalAtomicApi::class, ExperimentalCoroutinesApi::class)
class BleNostrClient(
val peer: BlePeer,
val transport: BleTransport,
@@ -64,8 +69,8 @@ class BleNostrClient(
private var connected = false
private val assembler = BleChunkAssembler()
- private val sendQueue = ArrayDeque>()
- private var isSending = false
+ private val sendQueue = Channel>(Channel.UNLIMITED)
+ private val isSending = AtomicBoolean(false)
override fun isConnected(): Boolean = connected
@@ -96,12 +101,9 @@ class BleNostrClient(
val json = OptimizedJsonMapper.toJson(cmd)
val chunks = BleMessageChunker.splitIntoChunks(json, chunkSize)
- synchronized(sendQueue) {
- sendQueue.addLast(chunks)
- if (!isSending) {
- isSending = true
- sendNextMessage()
- }
+ sendQueue.trySend(chunks)
+ if (!isSending.exchange(true)) {
+ sendNextMessage()
}
listener.onSent(this, json, cmd, true)
@@ -110,10 +112,7 @@ class BleNostrClient(
override fun disconnect() {
connected = false
assembler.reset()
- synchronized(sendQueue) {
- sendQueue.clear()
- isSending = false
- }
+ drainQueue()
transport.disconnectFromPeer(peer)
listener.onDisconnected(this)
}
@@ -132,10 +131,7 @@ class BleNostrClient(
fun onDisconnected() {
connected = false
assembler.reset()
- synchronized(sendQueue) {
- sendQueue.clear()
- isSending = false
- }
+ drainQueue()
listener.onDisconnected(this)
}
@@ -165,21 +161,28 @@ class BleNostrClient(
private var currentChunkIndex = 0
private fun sendNextMessage() {
- val chunks =
- synchronized(sendQueue) {
- sendQueue.removeFirstOrNull()
- }
- if (chunks == null) {
- synchronized(sendQueue) {
- isSending = false
+ val result = sendQueue.tryReceive()
+ if (result.isFailure) {
+ isSending.store(false)
+ // Double-check: an item may have been added after tryReceive
+ // but before we cleared the flag.
+ if (sendQueue.isEmpty) return
+ if (!isSending.exchange(true)) {
+ sendNextMessage()
}
return
}
- currentChunks = chunks
+ currentChunks = result.getOrNull()
currentChunkIndex = 0
sendNextChunk()
}
+ private fun drainQueue() {
+ while (sendQueue.tryReceive().isSuccess) {}
+ currentChunks = null
+ isSending.store(false)
+ }
+
private fun sendNextChunk() {
val chunks = currentChunks ?: return
if (currentChunkIndex >= chunks.size) {
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt
index 2a0b493e6..5a8d6d39b 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrServer.kt
@@ -29,6 +29,10 @@ import com.vitorpamplona.quartz.nipBEBle.protocol.BleChunkAssembler
import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport
import com.vitorpamplona.quartz.utils.Log
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlin.concurrent.atomics.AtomicBoolean
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Handles the server (relay) side of a BLE Nostr connection.
@@ -52,6 +56,7 @@ import com.vitorpamplona.quartz.utils.Log
* server.sendMessage(EventMessage(subId, event))
* ```
*/
+@OptIn(ExperimentalAtomicApi::class, ExperimentalCoroutinesApi::class)
class BleNostrServer(
val peer: BlePeer,
val transport: BleTransport,
@@ -60,8 +65,8 @@ class BleNostrServer(
) {
private var connected = false
private val assembler = BleChunkAssembler()
- private val sendQueue = ArrayDeque>()
- private var isSending = false
+ private val sendQueue = Channel>(Channel.UNLIMITED)
+ private val isSending = AtomicBoolean(false)
fun isConnected(): Boolean = connected
@@ -79,10 +84,7 @@ class BleNostrServer(
fun onClientDisconnected() {
connected = false
assembler.reset()
- synchronized(sendQueue) {
- sendQueue.clear()
- isSending = false
- }
+ drainQueue()
listener.onClientDisconnected(this)
}
@@ -110,12 +112,9 @@ class BleNostrServer(
val json = OptimizedJsonMapper.toJson(msg)
val chunks = BleMessageChunker.splitIntoChunks(json, chunkSize)
- synchronized(sendQueue) {
- sendQueue.addLast(chunks)
- if (!isSending) {
- isSending = true
- sendNextMessage()
- }
+ sendQueue.trySend(chunks)
+ if (!isSending.exchange(true)) {
+ sendNextMessage()
}
}
@@ -139,21 +138,28 @@ class BleNostrServer(
private var currentChunkIndex = 0
private fun sendNextMessage() {
- val chunks =
- synchronized(sendQueue) {
- sendQueue.removeFirstOrNull()
- }
- if (chunks == null) {
- synchronized(sendQueue) {
- isSending = false
+ val result = sendQueue.tryReceive()
+ if (result.isFailure) {
+ isSending.store(false)
+ // Double-check: an item may have been added after tryReceive
+ // but before we cleared the flag.
+ if (sendQueue.isEmpty) return
+ if (!isSending.exchange(true)) {
+ sendNextMessage()
}
return
}
- currentChunks = chunks
+ currentChunks = result.getOrNull()
currentChunkIndex = 0
sendNextChunk()
}
+ private fun drainQueue() {
+ while (sendQueue.tryReceive().isSuccess) {}
+ currentChunks = null
+ isSending.store(false)
+ }
+
private fun sendNextChunk() {
val chunks = currentChunks ?: return
if (currentChunkIndex >= chunks.size) {