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 = "![${alt ?: ""}](${state.result.url})" - message = message.insertUrlAtCursor(markdownImage) + message.insertUrlAtCursor(markdownImage) } } @@ -559,7 +561,7 @@ class LongFormPostViewModel : title = TextFieldValue("") summary = TextFieldValue("") coverImageUrl = "" - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") publishedAt = TimeUtils.now() showPreview = false @@ -594,11 +596,9 @@ class LongFormPostViewModel : this.multiOrchestrator?.remove(selected) } - override fun updateMessage(newMessage: TextFieldValue) { - message = newMessage - + override fun onMessageChanged() { if (message.selection.collapsed) { - val lastWord = newMessage.currentWord() + val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestions?.processCurrentWord(lastWord) @@ -626,7 +626,7 @@ class LongFormPostViewModel : userSuggestions?.let { userSuggestions -> if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) + userSuggestions.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.value = TextFieldValue("") @@ -641,7 +641,7 @@ class LongFormPostViewModel : fun autocompleteWithEmoji(item: EmojiMedia) { val wordToInsert = ":${item.code}:" - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() draftTag.newVersion() } @@ -655,21 +655,21 @@ class LongFormPostViewModel : } } - message = message.replaceCurrentWord(wordToInsert) + message.replaceCurrentWord(wordToInsert) emojiSuggestions?.reset() draftTag.newVersion() } fun canPost(): Boolean = title.text.isNotBlank() && - message.text.isNotBlank() && + message.text.toString().isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && multiOrchestrator == null fun insertAtCursor(newElement: String) { - message = message.insertUrlAtCursor(newElement) + message.insertUrlAtCursor(newElement) } fun selectImage(uris: ImmutableList) { @@ -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) {