diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md
index f9bc30f72a..2bb00d90e9 100644
--- a/.claude/skills/find-missing-translations/SKILL.md
+++ b/.claude/skills/find-missing-translations/SKILL.md
@@ -15,6 +15,18 @@ Extract string resource keys from the default `values/strings.xml` that are abse
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
+## Background: Crowdin strip-identical behavior
+
+This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
+
+Consequences for this skill:
+
+1. **A "missing" key on disk is not always actionable.** It may be Crowdin-stripped (translator already chose source-identical and Crowdin didn't export it) rather than genuinely new.
+2. **Don't add source-identical fallbacks locally.** Android's resource resolution falls back to `values/strings.xml` at runtime, so the user already sees the correct text. Local additions will be silently overwritten on Crowdin's next sync anyway.
+3. **The only actionable cases are keys Crowdin has never exported.** Whether the translator picked "use English" or simply hasn't translated the key yet, both states are owned by Crowdin and look identical on disk. The local repo cannot distinguish them.
+
+The Step 2.5 filter below uses the **most recent Crowdin export commit reachable from `HEAD`** (subject: `"New Crowdin translations by GitHub Action"`) as the cutoff: any key added to `values/strings.xml` after that commit is genuinely new (Crowdin hasn't exported it yet); anything older is Crowdin's responsibility regardless of why it's missing. The reachable-from-HEAD check survives the common workflow of deleting the `l10n_crowdin_translations` branch after merging.
+
## Target Locales
The default set of locales (unless the user specifies otherwise):
@@ -51,6 +63,59 @@ comm -23 \
This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales.
+> **Caveat:** Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys). If the cs-rCZ list looks suspiciously short, run the same diff for each target locale individually and union the results before Step 2.5.
+
+### 2.5. Filter out keys Crowdin has already seen (sync-timestamp check)
+
+A missing key is **only actionable if Crowdin has never exported it**. Once a key has been pushed to Crowdin and exported back, the translator may have chosen "use English" — Crowdin stores that choice in its own database and strips the entry from the exported `strings.xml`. From disk we cannot tell "translator picked English" from "Crowdin never saw the key": both look identical.
+
+The reliable signal is **time**: compare when the key was added to `values/strings.xml` against the timestamp of the **most recent Crowdin export that has been merged into the current branch**. Crowdin's GitHub Action produces commits with the literal subject `New Crowdin translations by GitHub Action`; finding the latest such commit reachable from `HEAD` works even if the `l10n_crowdin_translations` branch has been deleted post-merge (a common cleanup workflow).
+
+- Key added **before** that commit → Crowdin saw it on a prior export; translator made a decision; the absence on disk is a deliberate "use English" or "leave blank" choice. **Skip.**
+- Key added **after** → Crowdin has not exported it yet; genuinely new and actionable.
+
+```bash
+# Latest Crowdin export reachable from HEAD (survives branch deletion).
+sync_ts=$(git log -1 --format=%ct --grep='^New Crowdin translations by GitHub Action$' 2>/dev/null)
+if [ -z "$sync_ts" ]; then
+ echo "WARNING: no Crowdin export commit found in history; treating all missing as actionable" >&2
+ sync_ts=0
+else
+ echo "Crowdin sync cutoff: $(git log -1 --format='%ci %h' --grep='^New Crowdin translations by GitHub Action$')"
+fi
+
+# For each locale, list only keys added after the Crowdin sync (truly new).
+for locale in cs-rCZ de-rDE sv-rSE; do
+ echo "=== $locale: genuinely new (post-sync) keys ==="
+ comm -23 \
+ <(grep '` misses other resource types
-- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
+- **Treating every missing key as actionable** — Crowdin strips on export any translation the translator marked as "use English", and we cannot distinguish that from "never seen" by looking at disk. Use the Step 2.5 sync-timestamp filter: only keys added to `values/strings.xml` after the last `l10n_crowdin_translations` sync are genuinely new.
+- **Trying to detect "stripped" from git history alone** — the on-disk locale file only sees keys the translator typed a non-identical value for. The "translator opened the key and picked English from the start" case never touches disk, so a history-only check misses it. Use the sync-timestamp cutoff instead.
+- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so there is no user-visible bug to fix.
+- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale, then apply the sync-timestamp filter.
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c158668108..d9ed078f30 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -39,7 +39,7 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Linter (gradle)
- run: ./gradlew spotlessCheck
+ run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
build-desktop:
needs: lint
@@ -186,6 +186,65 @@ jobs:
name: ${{ matrix.desktop-artifact-name }}
path: ${{ matrix.desktop-artifact-path }}
+ test-quartz-ios:
+ # Phase 1 of the iOS support plan
+ # (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
+ # so JVM-only imports can't sneak into commonMain unnoticed. The
+ # `verifyKmpPurity` task in the lint job is the fast pre-check (Linux,
+ # ~1s); this job is the real one — compiles for the device variant
+ # and actually runs the simulator test suite.
+ needs: lint
+ runs-on: macos-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: 21
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/main' }}
+
+ # Two tasks, two purposes:
+ # - iosSimulatorArm64Test runs the existing iosTest suite on the
+ # simulator (NIP-04 / NIP-17 / NIP-19 / NIP-49 / AES-GCM /
+ # Chatroom keys), exercising secp256k1 and CryptoKit-backed
+ # primitives on a real Apple toolchain.
+ # - compileTestKotlinIosArm64 catches any device-only compile drift
+ # (iosArm64 = aarch64-apple-ios) without needing a physical
+ # device to run on. Compile-only is enough — running on-device
+ # would require xcodebuild + a provisioning profile.
+ - name: Test Quartz on iOS
+ run: |
+ ./gradlew \
+ :quartz:iosSimulatorArm64Test \
+ :quartz:compileTestKotlinIosArm64
+
+ # :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the
+ # iOS plan. Compile-only for now — actual UI / lifecycle wiring will
+ # land with the iosApp module in Phase 3. The container that runs Claude
+ # Code can't extract the Kotlin/Native LLVM toolchain (sandbox limit),
+ # so this is the first place commonMain Compose code is actually
+ # type-checked against an Apple Native frontend.
+ - name: Compile Commons for iOS
+ run: |
+ ./gradlew \
+ :commons:compileKotlinIosSimulatorArm64 \
+ :commons:compileKotlinIosArm64
+
+ - name: Upload iOS Test Reports
+ uses: actions/upload-artifact@v7
+ if: failure()
+ with:
+ name: Quartz iOS Test Reports
+ path: quartz/build/reports
+
test-and-build-android:
needs: lint
runs-on: ubuntu-latest
diff --git a/PRIVACY.md b/PRIVACY.md
index cfed3b5e56..08ef1e94ea 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,54 +1,114 @@
# Amethyst Privacy Policy and Terms of Use
-## Privacy Policy
+**App:** Amethyst (Android Nostr client)
+**Publisher:** Vitor Pamplona
+**Contact:** amethyst@vitorpamplona.com
+**Last updated:** 2026-05-24
-Effective as of Jun 12, 2023
+Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
-The Amethyst app for Android does not collect or process any personal information from its users.
+Amethyst lets you browse content from third-party Nostr **relays** that you choose. Those relays host the content. They are independent of Amethyst, with their own operators and their own policies.
-The app is used to browse third-party Nostr servers (called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
+This document explains what data leaves your phone, who can see it, and the standards that apply to use of the app.
-The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
+## Privacy
-Amethyst offers several options for uploading pictures and videos to post online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
+### Data sent off-device
-### Privacy with Relay services
+Using the app causes the following data to leave your phone:
-Your Internet Protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g., a VPN) from trackers online.
+- **Nostr events** you publish, sent to the relays you have configured.
+- **Subscriptions** (filters describing what you want to read), sent to those relays.
+- **Media uploads** (images, audio, video), sent to the media server you select.
+- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app.
+- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy).
-The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
+The developer does not run any server that aggregates or stores this data.
-Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs.
+### Data stored on your device
-While the content of direct messages (DMs) is only visible to you and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link into the DM text. Your uploaded pictures are available to anyone with that direct link.
+Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling.
-### Visibility & Permanence of Your Content on Nostr Relays
+### What relays can see
-#### Information Visibility
+A relay you connect to sees:
-Content that you share can be shared with other relays by any user of the network.
-The information you share is publicly visible to anyone reading from relays that have access to your information. Your information may also be visible to Nostr users who do not share relays with you.
+- Your IP address (or the Tor exit node when using it).
+- Your public key.
+- The events you publish (posts, reactions, reposts, reports, etc.).
+- The filters you subscribe to.
-#### Information Permanence
+A relay does **not** see the plaintext of:
-Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
+- Private Direct Messages (encrypted to the recipient under NIP-17 / NIP-44).
+- Private Zaps.
-## Child safety standards
+A relay can still see *that* you and another user are exchanging DMs even though it cannot read them. To reduce what a relay can correlate to you, route the app over a VPN or Tor.
-Amethyst does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. The application is 17+. We rely on Google Play's age verification to make sure the user downloading the app is an adult. Since we do not control which relays the user connects to, there is no content moderation beyond the standard block post, block account, and report post and/or account that will hide the content from the user.
+### Media uploads
+
+Uploads go to the media server you select. That server is independent of Amethyst and has its own policy. Anyone holding the resulting link — including media attached to a DM — can fetch the file.
+
+### Public content is effectively permanent
+
+Anything you publish to a relay can be copied to other relays or clients. Once published, you should assume it cannot be reliably deleted from the network.
+
+## Child Safety Standards
+
+These are the published Child Safety Standards for **Amethyst**, the Android Nostr client published on Google Play by **Vitor Pamplona**. They are published under Google Play's Child Safety Standards policy.
+
+They are a community standard, not a license restriction. Amethyst's source code remains licensed under the MIT License in `LICENSE`.
+
+### Prohibition
+
+Using Amethyst to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material — including child sexual abuse material (CSAM) — or to groom, exploit, or harm a minor is prohibited and is illegal in essentially every jurisdiction.
+
+### In-app tools
+
+Amethyst provides:
+
+- **Report Post** and **Report Account** — publish a signed Nostr report (including the "Illegal Content" reason) so relays and other clients can act on it.
+- **Block Post** / **Block Account** — hide content locally on your device.
+- **Block Relay** — add a relay to your NIP-51 Blocked Relay List so the app stops fetching from or publishing to it. This is the strongest tool the app offers against a relay that refuses to moderate.
+- **Mute Words / Hashtags** — filter unwanted content from your feeds.
+
+### Addressing CSAM
+
+Amethyst does not host content, so the app cannot remove CSAM. Only the relay hosting the content can remove it. In the United States, 18 U.S.C. §2258A makes hosting providers — not viewer applications — the entities required to report to the National Center for Missing & Exploited Children (NCMEC).
+
+If you encounter CSAM through Amethyst:
+
+1. Report the content in-app and select "Illegal Content."
+2. Add the hosting relay to your Blocked Relay List.
+3. Report directly to **NCMEC** at https://report.cybertip.org/ (United States) or to an **INHOPE** hotline at https://www.inhope.org/ (other jurisdictions). These bodies can compel the hosting provider to act.
+4. You may also email **amethyst@vitorpamplona.com** with the relay URL and event ID. The developer cannot remove content from third-party relays, but may forward the report to relay operators it is in contact with and may stop recommending the offending relay in any list shipped with the app.
+
+### Compliance
+
+Amethyst is distributed under Google Play's Child Safety Standards policy and applicable law. Obligations attached to the **hosting** of content rest with relay operators.
+
+### Age rating
+
+Amethyst's Google Play listing is rated 17+. The app does not request or store age information.
## Terms of Use
-### For versions downloaded from Google's Play Store
+### Google Play build
-You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
+You agree not to use the Google Play build of Amethyst to submit Objectionable Content to relays. Objectionable Content includes:
-### For versions downloaded from F-Droid
+- Sexually explicit material.
+- Obscene, defamatory, libelous, slanderous, violent, or unlawful content.
+- Content that infringes third-party rights (copyright, trademark, privacy, publicity).
+- Content that is deceptive or fraudulent.
+- Content promoting illegal drugs, tobacco, firearms, ammunition, or illegal gambling.
-We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid.
+These Terms apply only to the Google Play distribution of Amethyst.
-## Other Notes
+### F-Droid and other source-built distributions
-We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use.
+The MIT License in `LICENSE` is the only instrument governing your right to use, study, modify, and redistribute the software. No additional terms are imposed on these builds. Any dispute over distribution through F-Droid is between you and F-Droid.
-If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
+## Updates
+
+This document may change. The current version is published at https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md.
diff --git a/amethyst/plans/2026-05-24-ios-support.md b/amethyst/plans/2026-05-24-ios-support.md
new file mode 100644
index 0000000000..340b2eeb24
--- /dev/null
+++ b/amethyst/plans/2026-05-24-ios-support.md
@@ -0,0 +1,390 @@
+# iOS Support for Amethyst
+
+**Date:** 2026-05-24
+**Status:** Phase 1 complete; Phase 2 in flight
+**Owner:** TBD
+
+Plan to incrementally bring Amethyst to iOS by extending the existing
+KMP layers from the bottom up. Each phase is independently shippable —
+we can pause between any two phases without leaving the tree in a
+broken state.
+
+## Why this is tractable today
+
+The structural work that usually dooms a KMP-to-iOS effort is already
+done:
+
+- `quartz/` has `iosArm64` + `iosSimulatorArm64` targets configured,
+ a working `Platform.ios.kt` actual, and 6 iOS test files that pass.
+- Jackson and OkHttp — the two big JVM-only dependencies — are *already
+ isolated to `jvmAndroid`* in quartz (`quartz/src/jvmAndroid/.../jackson/`,
+ `quartz/src/jvmAndroid/.../okhttp/`). `commonMain` is JVM-free except
+ for the obvious `kotlinx.*` stack.
+- `commons/commonMain` has exactly **one** Jackson reference
+ (`FeedDefinitionSerializer.kt`) and zero OkHttp references. The rest
+ of the JVM stickiness lives in `jvmAndroid` / `jvmMain` / `androidMain`,
+ which is where it belongs.
+- Compose Multiplatform 1.10.3 is in use, which supports iOS officially.
+- `secp256k1-kmp` ships iOS targets. `androidx.collection` (LruCache) and
+ `androidx.lifecycle.viewmodel.compose` are KMP since 2.8.
+
+What this means: we are not embarking on a months-long "purify
+commonMain" migration before any iOS code can compile. Phase 1 is
+mostly **add iOS to CI** and **patch the last few leaks**.
+
+## Module-by-module dep matrix
+
+Status legend:
+- ✅ iOS-ready (targets configured, no JVM-only deps in shared code)
+- 🟡 Partial (intermediate source sets need adding, but no major dep blockers)
+- 🔴 Blocked (significant native work required)
+- ⛔ Out of scope (won't ship on iOS)
+
+| Module | Today | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 |
+|---|---|---|---|---|---|---|
+| `quartz/` | ✅ | CI + audit | — | — | — | — |
+| `commons/` (non-UI) | 🟡 | — | ✅ | — | — | — |
+| `commons/` (UI) | 🟡 | — | — | ✅ | — | — |
+| `iosApp/` (new) | n/a | — | — | scaffold | feature-complete | — |
+| `quic/` | 🔴 | — | — | — | — | iOS actuals |
+| `nestsClient/` | 🔴 | — | — | — | — | iOS actuals |
+| `amethyst/` (app) | ⛔ | — | — | — | — | — |
+| `desktopApp/` | ⛔ | — | — | — | — | — |
+| `cli/` | ⛔ | — | — | — | — | — |
+
+## Source-set diagram (target end state)
+
+```
+commons/src/
+├── commonMain/ ── all targets
+│ ├── coreMain/ ── ViewModels, state, DAL (no Compose)
+│ │ ├── jvmAndroidCore/ ── Android + Desktop
+│ │ │ ├── androidCore/
+│ │ │ └── jvmCore/
+│ │ └── nativeCore/ ── iOS
+│ │ ├── iosArm64Core/
+│ │ └── iosSimArm64Core/
+│ └── uiMain/ ── Compose UI, icons, resources
+│ ├── jvmAndroidUi/
+│ │ ├── androidUi/
+│ │ └── jvmUi/
+│ └── nativeUi/ ── iOS Compose
+```
+
+(Names sketched for clarity; in practice we'll fold `coreMain` /
+`uiMain` together once *every* file in `uiMain` compiles for iOS —
+the split is a transitional scaffold for Phase 2 ↔ Phase 3.)
+
+`quartz/`, `quic/`, `nestsClient/` already use a `jvmAndroid` shared
+source set; we'll add a sibling `nativeMain` (or just `iosMain` where
+that's simpler) when each module turns on iOS.
+
+---
+
+## Phase 1 — Lock down Quartz on iOS
+
+**Duration estimate:** 1–2 weeks
+**Deliverable:** `./gradlew :quartz:iosSimulatorArm64Test` runs in CI on every PR.
+
+### Tasks
+
+1. **Add iOS to CI for `:quartz`.**
+ - GitHub Actions macOS runner step: `iosSimulatorArm64Test` +
+ `iosArm64SourceSetTest` (compile only).
+ - This is the single most valuable change in the entire plan — it
+ prevents anyone from accidentally re-adding a JVM-only import to
+ `commonMain`.
+
+2. **Audit the `jvmAndroid` boundary.**
+ - Confirm everything Jackson/OkHttp-related lives in `jvmAndroid`
+ (it does today — keep it that way).
+ - Add a checkstyle / detekt rule, or a simple grep gate in CI, that
+ fails the build if `com.fasterxml.jackson` or `okhttp3` shows up
+ in `commonMain`.
+
+3. **Validate `secp256k1` iOS path.**
+ - Make sure `KeyPair`, `SchnorrSigner`, NIP-44 v2 vectors run green
+ on `iosSimulatorArm64Test`.
+ - The iOS tests already cover NIP-04 / NIP-17 / NIP-19 / NIP-49 — we
+ just need to surface them in CI.
+
+4. **Plan the `expect`/`actual` for iOS HTTP.**
+ - Phase 1 only sketches the design; the actual `Ktor-darwin` wiring
+ lands in Phase 2 when `:commons` needs it.
+ - Decide: Ktor everywhere, vs OkHttp on JVM/Android + Ktor on iOS.
+ **Recommendation:** keep OkHttp on JVM/Android (we use OkHttp-specific
+ features in relay reconnect logic) and add an iOS-only Ktor actual.
+
+### Risks
+
+- None major. The work here is mostly defensive.
+
+---
+
+## Phase 2 — Bring `:commons` to iOS, non-UI first
+
+**Duration estimate:** 2–3 weeks
+**Deliverable:** `./gradlew :commons:iosSimulatorArm64Test` compiles every
+shared ViewModel and state class.
+
+### Tasks
+
+1. **Add iOS targets to `commons/build.gradle.kts`.**
+ - `iosArm64()` + `iosSimulatorArm64()`.
+ - Introduce intermediate source sets `coreMain` (all targets) and
+ `uiMain` (JVM + Android only, for now).
+
+2. **Migrate `FeedDefinitionSerializer.kt` off Jackson.**
+ - Move to `kotlinx.serialization`, OR
+ - Push it down into `jvmAndroidCore` and create a `nativeCore` actual.
+ **Recommendation:** migrate. It's one file; one-time cost is small;
+ reduces split-actual surface area forever.
+
+3. **Add `expect`/`actual` wrappers for JVM-only deps used by ViewModels.**
+
+ | Concern | JVM/Android | iOS actual |
+ |---|---|---|
+ | HTTP client | OkHttp | Ktor + `Ktor-darwin` |
+ | Secure key storage | Android Keystore / java-keyring | Keychain Services |
+ | EXIF strip (image upload) | `commons-imaging` | `ImageIO` (`CGImageSourceCopyPropertiesAtIndex`) |
+ | File I/O paths | `java.io.File` | `NSFileManager` / `okio` |
+ | Logging | `android.util.Log` / SLF4J | `os_log` via cinterop, or plain `println` to start |
+
+4. **Compile-only iOS for `:commons` ViewModels.**
+ - At the end of Phase 2 we have ViewModels, account state, LocalCache
+ wrappers, filter assemblers, and `ComposeSubscriptionManager` building
+ on iOS — but no UI yet.
+ - Smoke test: write a small `commonTest` that constructs an `Account`,
+ subscribes to a stub relay, and verifies a follow event lands in
+ `LocalCache`. Run it on iOS simulator.
+
+### Risks
+
+- **Ktor migration scope creep.** Hold the line: Phase 2 only wraps HTTP
+ behind `expect`. Don't refactor the relay pool. That's a separate PR.
+- **Coroutines dispatcher differences.** `Dispatchers.IO` does not exist on
+ Kotlin/Native by default — code that explicitly references it needs a
+ `KmpDispatchers.IO` shim. Audit before Phase 2 starts.
+
+### Phase 2 audit (2026-05-24): commons/commonMain iOS-blocker inventory
+
+Audit of all 335 .kt files in `commons/src/commonMain/`. Better than feared
+— most files are already KMP-clean. The actual blockers are 21 files
+across ~6 distinct concerns. Each row below is a small mergeable PR.
+
+**By blocker category:**
+
+| Blocker | Files | Fix |
+|---|---|---|
+| `java.util.Base64` | 1 (`Base64Image.kt`) | `kotlin.io.encoding.Base64` (stdlib since 1.8) |
+| `AtomicLong` / `AtomicInteger` | 2 (`ChessLobbyState.kt`, `SigningState.kt`) | `kotlinx.atomicfu.atomic` |
+| `ConcurrentHashMap` | 4 (`ChessRelayFetchHelper.kt`, `ChessEventCollector.kt`, `ComposeSubscriptionManager.kt`, `MutableComposeSubscriptionManager.kt`) | `androidx.collection.MutableScatterMap` (KMP) — synchronization most likely already provided by enclosing scope; audit per file |
+| `SortedSet` + `ConcurrentSkipListSet` | 2 (`EventListMatchingFilter.kt`, `NoteListMatchingFilter.kt`) | Switch to `mutableListOf` + sort-on-access, or `androidx.collection.MutableScatterSet` with manual order |
+| `WeakReference` | 5 (`Channel.kt`, `Chatroom.kt`, `MarmotGroupChatroom.kt`, `UserRelaysCache.kt`, **+1**) | `expect class KmpWeakReference` actuals: JVM `java.lang.ref.WeakReference`; iOS `kotlin.native.ref.WeakReference` |
+| `BigDecimal` | 1 (`Note.kt`) | Either KMP bignum lib (`com.ionspin:bignum`) or move the BigDecimal-using helper to `jvmAndroid` and stub on iOS |
+| `java.io.File` | 1 (`MediaContentModels.kt`) | Replace with `String` path, or `okio.Path` |
+| `java.net.URI` / `MalformedURLException` | 2 (`RichTextParser.kt`, `UrlInfoItem.kt`) | KMP URL lib (`io.ktor:ktor-http`) or stay JVM via expect/actual `parseUrl()` |
+| `java.nio.charset.Charset` | 1 (`HtmlCharsetParser.kt`) | `kotlin.text.Charsets` for UTF-8/16; for arbitrary charsets, expect/actual |
+| `:nestsClient` project dep | 2 (`NestViewModel.kt`, `ActiveSubscription.kt`) | Move both files to `jvmAndroid` source set (audio rooms are Phase 5 anyway) |
+| `com.halilibo.richtext.*` | 1 (`RenderMarkdown.kt`) | Verify iOS artifact; if missing, move to `jvmAndroid` until Phase 3 markdown decision |
+
+**Files that look scary but aren't:**
+- 183 files import `androidx.compose.*` — these all map to JetBrains Compose
+ Multiplatform's iOS artifacts (identical package paths). No work needed.
+- 7 files import `androidx.lifecycle.*` — KMP since 2.8.0. No work needed.
+- 0 files import `coil3.network.okhttp` (Coil network is already isolated).
+- 0 files import `javax.*` or `android.*` directly from commonMain.
+
+**Recommended PR order** (ascending cost, descending obviousness):
+
+1. ✅ Phase 1 complete (gates + iOS CI for quartz, Jackson migration).
+2. **Base64** (1 file, ~2 LOC change). Demonstrates the pattern.
+3. **Atomics** (2 files, atomicfu plugin + ~10 LOC).
+4. **ConcurrentHashMap** (4 files; needs concurrency audit per file).
+5. **WeakReference** (5 files + 1 new expect/actual).
+6. **`:nestsClient` files → jvmAndroid** (2 files; pure source-set move).
+7. **`RenderMarkdown.kt` → jvmAndroid OR iOS verification** (1 file; depends on lib check).
+8. **URL parsing** (2 files; either ktor-http dep or expect/actual).
+9. **Charsets, BigDecimal, File** (3 files; small per-file decisions).
+10. After ~9 lands: add iOS targets to `:commons`, expect failures to be down
+ to ~zero, run `compileKotlinIosSimulatorArm64` to confirm.
+11. Then proceed with the original Phase 2 plan items (Ktor for HTTP,
+ SecureKeyStore expect/actual, etc.) for the cross-cutting deps.
+
+---
+
+## Phase 3 — Compose Multiplatform UI on iOS
+
+**Duration estimate:** 3–4 weeks
+**Deliverable:** A read-only iOS `.ipa` on TestFlight internal that connects
+to relays and renders a feed.
+
+### Tasks
+
+1. **Flip `uiMain` to target = all (including iOS).**
+ - Compose Multiplatform 1.10.3 supports iOS. The Material Symbols font
+ and other Compose Resources already work cross-platform.
+
+2. **Audit UI deps for iOS.**
+
+ | Dep | Status | Action |
+ |---|---|---|
+ | `jetbrains.compose.*` (1.10.3) | ✅ | None |
+ | `androidx.lifecycle.viewmodel.compose` 2.8+ | ✅ KMP | None |
+ | `coil3` | ✅ iOS | Swap network fetcher from `coil-okhttp` to `coil-ktor` on iOS via source-set split |
+ | `markdown-ui` / `markdown-ui-material3` | ⚠️ Verify | Likely OK on iOS; if not, fall back to commonmark + custom renderer |
+ | `kotlinx-collections-immutable` | ✅ | None |
+ | Material Symbols font | ✅ | None (already via Compose Resources) |
+
+3. **Create the `iosApp/` module.**
+ - SwiftUI `App` + `UIViewControllerRepresentable` hosting
+ `ComposeUIViewController { App() }`.
+ - Tab bar (UIKit) for top-level navigation, Compose for each tab's
+ content area. **Same split philosophy as Desktop**: native shell,
+ shared content.
+ - Add Xcode project + Gradle Kotlin/Native framework wiring (no
+ CocoaPods; use the JetBrains-recommended `embedAndSignAppleFrameworkForXcode`).
+
+4. **Ship a "read-only Nostr browser" first cut.**
+ - Profile view, single-feed home, NoteCard rendering, image loading,
+ basic navigation.
+ - No posting, no DMs, no audio rooms.
+ - This validates the *entire* stack — relay client, LocalCache, feed DAL,
+ NoteCard composable, Coil 3, Compose Resources, font rendering — without
+ touching signing.
+
+### Risks
+
+- **Compose iOS performance on large feeds.** Profile early with a realistic
+ `LocalCache` (10k+ notes) before locking screen architecture. If recomposition
+ storms appear, lean harder on `compose-stability-diagnostics` and
+ `compose-state-deferred-reads` skills.
+- **Touch interactions vs Android conventions.** Pull-to-refresh, swipe
+ back, long-press menus all differ on iOS. Some screens may need
+ platform-specific gesture handling.
+- **Markdown rendering library iOS support.** If `markdown-ui-material3`
+ doesn't ship iOS artifacts, this is a half-week detour to switch
+ renderers. Verify in week 1 of Phase 3.
+
+---
+
+## Phase 4 — Write paths: signing, posting, settings
+
+**Duration estimate:** 2–3 weeks
+**Deliverable:** Fully read/write iOS client, minus audio rooms.
+
+### Tasks
+
+1. **Wire `NostrSignerInternal` to Keychain.**
+ - The signer is already KMP — only the key storage actual needs
+ adding (done in Phase 2's `SecureKeyStore` abstraction).
+
+2. **Make `NostrSignerRemote` (NIP-46 bunker) work on iOS.**
+ - Should be KMP-clean once Ktor migration is done. Audit for any
+ stray Jackson / OkHttp inside the NIP-46 path.
+
+3. **NIP-55 alternative.**
+ - **There is no Amber on iOS.** Plan replacements:
+ - Push users toward NIP-46 bunkers (Nsec.app, Amber-as-bunker,
+ remote nostr-connect URIs).
+ - URL-scheme handoff to native iOS signers (`nos2x-fhe`, `Nostore`)
+ *if* they expose a sign API. Track separately.
+ - Onboarding screen needs an iOS-specific copy variant.
+
+4. **Posting, reactions, zaps.**
+ - Mostly free — ViewModels already in `:commons`. Wire UI buttons and
+ test end-to-end on TestFlight.
+
+5. **Settings UI.**
+ - Share via Compose. iOS-native preference screens are a polish item
+ for later.
+
+### Risks
+
+- **Apple App Review on cryptocurrency / zaps.** Lightning zaps via LNURL
+ are fine (no in-app crypto purchase). Anything that looks like an
+ in-app wallet or onchain send may need legal review and / or feature
+ gating per-region. Start review conversations early.
+- **Push notifications.** APNs is the only path on iOS. Nostr DM push
+ relays don't speak APNs natively. Likely needs a small relay-proxy
+ (similar to `notify.damus.io`'s architecture). Design doc in
+ `amethyst/plans/` before Phase 4 ends.
+
+---
+
+## Phase 5 — `:quic` + `:nestsClient` for audio rooms (optional)
+
+**Duration estimate:** 4–6 weeks
+**Status:** Defer until 1–4 are solid. App is shippable on iOS without
+audio rooms.
+
+### Tasks
+
+1. **`:quic` — add iOS actuals.**
+ - UDP socket via `Network.framework` (`NWConnection` with `.udp`).
+ - AEAD (AES-GCM, ChaCha20-Poly1305) via Apple CryptoKit
+ (`AES.GCM.SealedBox`, `ChaChaPoly`).
+ - TLS state machine is already pure Kotlin in `commonMain` — no change.
+
+2. **`:nestsClient` — add iOS actuals.**
+ - Opus encode/decode: `libopus` via cinterop, or pull `opus.framework`
+ from a Swift Package / CocoaPods spec.
+ - Mic + speaker: `AVAudioEngine` (input/output nodes) instead of
+ `AudioRecord` / `AudioTrack`.
+
+3. **moq-lite listener path first** (the production path per CLAUDE.md),
+ then speaker.
+
+### Risks
+
+- **Background audio on iOS.** Audio rooms in the background need a
+ proper `AVAudioSession` category + the `audio` background mode in
+ `Info.plist`. Apple sometimes rejects apps that abuse this. Worth a
+ separate audit before submission.
+- **Opus framework distribution.** `libopus` via Swift Package is
+ cleanest; CocoaPods is fine but pulls in a build-time dep on Ruby.
+ Decide before Phase 5 starts.
+
+---
+
+## Phase 6 — Ship polish (ongoing, post-Phase 4)
+
+- App Store metadata, screenshots, privacy manifest
+ (`NSPrivacyAccessedAPI*` declarations — file access, user defaults).
+- Localizations carry over automatically via Compose Resources.
+- Background fetch limits — iOS is far stricter than Android. Tune
+ feed prefetch + relay reconnect for background launch budgets.
+- TestFlight beta → public release.
+
+---
+
+## Cross-cutting risks (track from day one)
+
+| Risk | Mitigation | First chance to catch |
+|---|---|---|
+| `commonMain` regresses with a JVM-only import | Add iOS to CI on every PR | Phase 1, task 1 |
+| Coroutines `Dispatchers.IO` ergonomics on iOS | Audit + introduce `KmpDispatchers` shim | Phase 2, task 3 |
+| Compose iOS performance on big feeds | Early profiling with realistic `LocalCache` | Phase 3 risk section |
+| App Store review (zaps, onchain) | Talk to legal / read App Store guidelines early | Phase 4 risk section |
+| No NIP-55 equivalent on iOS | Lean on NIP-46; document in onboarding | Phase 4, task 3 |
+| Push notifications via APNs | Relay-proxy design doc | Phase 4, end of phase |
+| Background audio policy | `AVAudioSession` audit + `Info.plist` review | Phase 5 risk section |
+
+## Suggested first PR
+
+Smallest useful start: **Phase 1, tasks 1 + 2** — add `:quartz` iOS to
+CI and add the import-gate that prevents Jackson / OkHttp regressions
+in `commonMain`. That single PR de-risks the rest of the plan without
+touching any product code.
+
+## Open questions
+
+- Do we want a `:cli` analogue on iOS (a "headless" Nostr daemon)? Out
+ of scope for this plan, but iosArm64 *could* host one if we ever need
+ a CLI-on-phone story.
+- Mac Catalyst vs native macOS: Desktop is already JVM-Compose. We
+ could theoretically also ship Catalyst from the iOS build, but that's
+ three "desktop"-ish targets to maintain. Recommendation: punt.
+- iPad layout: do we want a separate split-view UI like `desktopApp`,
+ or just scale up the iPhone layout? Phase 3 keeps the iPhone layout;
+ iPad polish is a Phase 6 item.
diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt
new file mode 100644
index 0000000000..65bf2ce996
--- /dev/null
+++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.screen.loggedIn.settings
+
+import androidx.compose.runtime.Composable
+
+// F-Droid distributes Amethyst as MIT-licensed free software; the build must
+// not surface links to external (e.g. GitHub-hosted) policy documents.
+@Composable
+fun LegalSettingsSection() {
+}
diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt
new file mode 100644
index 0000000000..736b2dc0d5
--- /dev/null
+++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.screen.loggedOff.legal
+
+import androidx.compose.runtime.Composable
+
+// F-Droid distributes Amethyst as MIT-licensed free software; there is no
+// terms-of-use acceptance layered on top of the source license, and the build
+// must not link to any external (e.g. GitHub) policy document.
+@Composable
+@Suppress("UNUSED_PARAMETER")
+fun TermsGate(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ showError: Boolean,
+) {
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt
index 4988276dde..7e4a5fc789 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt
@@ -261,6 +261,8 @@ fun AllSettingsScreen(
)
}
+ LegalSettingsSection()
+
SettingsSection(R.string.danger_zone, isDanger = true) {
if (hasPrivateKey) {
SettingsItem(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt
index 6aef504782..eef969c214 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt
@@ -72,8 +72,8 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
-import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms
import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup
+import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
@@ -197,18 +197,11 @@ fun LoginPage(
}
if (loginViewModel.isFirstLogin) {
- AcceptTerms(
+ TermsGate(
checked = loginViewModel.acceptedTerms,
onCheckedChange = loginViewModel::updateAcceptedTerms,
+ showError = loginViewModel.termsAcceptanceIsRequiredError,
)
-
- if (loginViewModel.termsAcceptanceIsRequiredError) {
- Text(
- text = stringRes(R.string.acceptance_of_terms_is_required),
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodySmall,
- )
- }
}
Spacer(modifier = Modifier.height(Size10dp))
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt
index 35d4bfff56..ef7e79f0bf 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
+import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
@@ -68,7 +69,7 @@ class LoginViewModel : ViewModel() {
) {
clear()
this.isFirstLogin = isFirstLogin
- acceptedTerms = !isFirstLogin
+ acceptedTerms = !isFirstLogin || BuildConfig.FLAVOR != "play"
if (newAccountKey != null) {
key = TextFieldValue(newAccountKey)
}
@@ -79,7 +80,7 @@ class LoginViewModel : ViewModel() {
password = TextFieldValue("")
errorManager.clearErrors()
- acceptedTerms = false
+ acceptedTerms = BuildConfig.FLAVOR != "play"
processingLogin = false
isTemporary = false
offerTemporaryLogin = false
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt
index 62616886ce..72797b6abe 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt
@@ -54,8 +54,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
-import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms
import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup
+import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -187,19 +187,12 @@ fun SignUpPage(
},
)
- AcceptTerms(
+ TermsGate(
checked = signUpViewModel.acceptedTerms,
onCheckedChange = signUpViewModel::updateAcceptedTerms,
+ showError = signUpViewModel.termsAcceptanceIsRequiredError,
)
- if (signUpViewModel.termsAcceptanceIsRequiredError) {
- Text(
- text = stringRes(R.string.acceptance_of_terms_is_required),
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodySmall,
- )
- }
-
Spacer(modifier = Modifier.height(Size10dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt
index 6f87efac7a..206878f39c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
+import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
@@ -40,7 +41,7 @@ class SignUpViewModel : ViewModel() {
var displayName by mutableStateOf(TextFieldValue(""))
- var acceptedTerms by mutableStateOf(false)
+ var acceptedTerms by mutableStateOf(BuildConfig.FLAVOR != "play")
var termsAcceptanceIsRequiredError by mutableStateOf(false)
fun init(accountSessionManager: AccountSessionManager) {
diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml
index 8f751e8640..f6f46d9a22 100644
--- a/amethyst/src/main/res/values-cs-rCZ/strings.xml
+++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml
@@ -14,6 +14,12 @@
- Tento příspěvek má více než %1$d hashtagů
- Tento příspěvek má více než %1$d hashtagů
+
+ - %1$d majetek je zbalený
+ - %1$d majetky jsou zbalený
+ - %1$d majetků jsou zbalený
+ - %1$d majetků jsou zbalený
+
Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací
👀
Obrázek kanálu
@@ -562,6 +568,11 @@
Vyberte, které z odznaků, které jste obdrželi, se zobrazí na vašem profilu.
Zatím jste neobdrželi žádné odznaky.
Obrázky
+ Aplikace
+ Aplikace
+ Zdroj: %1$s
+ v%1$s
+ Stáhnout
Kalendáře
Krátká videa
Veřejné chaty
@@ -1199,6 +1210,8 @@
Politika příspěvků
Zásady ochrany soukromí
Podmínky & ujednání
+ Standardy ochrany dětí
+ O aplikaci a právní informace
N/A
Chyby a upozornění z tohoto relé
Zprávy monitoru relé
@@ -1770,6 +1783,7 @@
%1$s · končí %2$s
Sdílet událost kalendáře
Exportovat do kalendáře (.ics)
+ calendar_reminders
Připomenutí kalendáře
Upozornění, když je událost, které se účastníte, blízko začátku.
Událost kalendáře
@@ -1797,6 +1811,12 @@
Oznámení se spustí, když je událost, které se účastníte, blízko začátku.
Doba předstihu připomenutí
Kolik minut před událostí chcete být upozorněni.
+
+ - %1$d min
+ - %1$d min
+ - %1$d min
+ - %1$d min
+
Sdílet jako Nostr odkaz
Sdílet odkaz na kalendář
Všechny kalendáře
@@ -1828,6 +1848,7 @@
Zvýšit nebo citovat
Olajkovat
Zap
+ On-chain Bitcoin zap
Čeká na potvrzení
Změnit rychlé reakce
Spodní navigační lišta
@@ -2788,6 +2809,7 @@
Nowhere Obchod
Nowhere Petice
Nowhere Zpráva
+ Nowhere Drop
Nowhere Umění
Nowhere Fórum
diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml
index cd69d1a59f..f4ef6844e3 100644
--- a/amethyst/src/main/res/values-de-rDE/strings.xml
+++ b/amethyst/src/main/res/values-de-rDE/strings.xml
@@ -12,6 +12,10 @@
- Dieser Beitrag hat mehr als %1$d Hashtag
- Dieser Beitrag hat mehr als %1$d Hashtags
+
+ - %1$d Asset gebündelt
+ - %1$d Assets bündelt
+
Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden
👀
Kanalbild
@@ -556,6 +560,11 @@ anz der Bedingungen ist erforderlich
Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen.
Du hast noch keine Abzeichen erhalten.
Bilder
+ Apps
+ Apps
+ Quelle: %1$s
+ v%1$s
+ Herunterladen
Kalender
Kurzvideos
Öffentliche Chats
@@ -1190,6 +1199,8 @@ anz der Bedingungen ist erforderlich
Veröffentlichungsrichtlinie
Datenschutzerklärung
Allgemeine Geschäftsbedingungen
+ Kinderschutzstandards
+ Über & Rechtliches
N/A
Fehler und Hinweise von diesem Relais
Relay-Überwachungsberichte
@@ -1681,6 +1692,7 @@ anz der Bedingungen ist erforderlich
Kalender bearbeiten
Termine in diesem Kalender (%1$d)
Du hast noch keine Kalendertermine erstellt.
+ Feed
Monat
Woche
Tag
@@ -1746,6 +1758,7 @@ anz der Bedingungen ist erforderlich
Komme
Vielleicht
Komme nicht
+ RSVPs (%1$d)
Noch keine Antworten.
Teilnehmer (%1$d)
In Kalendern (%1$d)
@@ -1755,6 +1768,7 @@ anz der Bedingungen ist erforderlich
%1$s · endet %2$s
Kalendertermin teilen
In Kalender exportieren (.ics)
+ calendar_reminders
Kalendererinnerungen
Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt.
Kalendertermin
@@ -1991,8 +2005,10 @@ anz der Bedingungen ist erforderlich
Geschlossen
Entwurf
Übersicht
+ Tickets
Patches und PRs
Über
+ Links
Maintainer
Themen
Persönlicher Fork
@@ -2757,8 +2773,12 @@ anz der Bedingungen ist erforderlich
Das aktuelle Community-Regeldokument wurde als veraltet abgelehnt.
Nowhere-Seite
+ Nowhere Ereignis
Nowhere Spendenaktion
Nowhere Shop
+ Nowhere Petition
Nowhere Nachricht
+ Nowhere Drop
Nowhere Kunst
+ Nowhere Forum
diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml
index 96415b3792..a991324f8f 100644
--- a/amethyst/src/main/res/values-hu-rHU/strings.xml
+++ b/amethyst/src/main/res/values-hu-rHU/strings.xml
@@ -12,6 +12,10 @@
- Ez a bejegyzés több mint %1$d kulcsszót tartalmaz
- Ez a bejegyzés több mint %1$d kulcsszót tartalmaz
+
+ - %1$d asset egybecsomagolva
+ - %1$d asset egybecsomagolva
+
Az esemény épp betöltődik vagy nem található az átjátszólistában
👀
Csatorna profilképe
@@ -552,6 +556,11 @@
Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban.
Ön még nem kapott kitűzőt.
Képek
+ Alkalmazások
+ Alkalmazások
+ Forrás: %1$s
+ v%1$s
+ Letöltés
Naptárak
Rövidek
Nyilvános csevegések
@@ -1189,6 +1198,8 @@
Közzétételi szabályzat
Adatvédelmi irányelvek
Általános szerződési feltételek
+ Gyermekbiztonsági szabványok
+ Névjegy & Jogi információk
Nem érhető el
Hibák és megjegyzések ettől az átjátszótól
Átjátszófigyelési jelentések
diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml
index 5f9ee02a4d..b25b0ffc3d 100644
--- a/amethyst/src/main/res/values-pl-rPL/strings.xml
+++ b/amethyst/src/main/res/values-pl-rPL/strings.xml
@@ -562,6 +562,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu.
Nie otrzymałeś jeszcze żadnych odznak.
Zdjęcia
+ Aplikacje
+ Aplikacje
+ Źródło: %1$s
+ v%1$s
+ Pobierz
Kalendarze
Filmiki
Czaty publiczne
@@ -1201,6 +1206,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
Polityka publikowania
Polityka Prywatności
Zasady i warunki użytkowania
+ Normy bezpieczeństwa dzieci
+ O Nas & Informacje Prawne
Nie dotyczy
Błędy i powiadomienia z tego transmitera
Raporty z monitoringu transmitera
diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml
index 010fb91c5a..1f6d0679ed 100644
--- a/amethyst/src/main/res/values-sv-rSE/strings.xml
+++ b/amethyst/src/main/res/values-sv-rSE/strings.xml
@@ -12,6 +12,10 @@
- Det här inlägget har fler än %1$d hashtagg
- Det här inlägget har fler än %1$d hashtaggar
+
+ - %1$d tillgång paketerad
+ - %1$d tillgångar buntade
+
Inlägg hittades inte
👀
Kanal bild
@@ -550,6 +554,11 @@
Välj vilka av märkena du fått som ska visas på din profil.
Du har inte fått några märken ännu.
Bilder
+ Appar
+ Appar
+ Källa: %1$s
+ v%1$s
+ Ladda ner
Kalendrar
Kortfilmer
Offentliga chattar
@@ -1184,6 +1193,8 @@
Publiceringspolicy
Integritetspolicy
Villkor & bestämmelser
+ Standarder för barnsäkerhet
+ Om och juridiskt
N/A
Fel och meddelanden från detta relä
Reläövervakningsrapporter
@@ -1751,6 +1762,7 @@
%1$s · slutar %2$s
Dela kalenderhändelse
Exportera till kalender (.ics)
+ calendar_reminders
Kalenderpåminnelser
Påminnelse när en händelse du deltar i snart ska börja.
Kalenderhändelse
@@ -1776,6 +1788,10 @@
En notis visas när en händelse du deltar i snart ska börja.
Påminnelsetid
Hur många minuter före händelsen du vill bli notifierad.
+
+ - %1$d min
+ - %1$d min
+
Dela som Nostr-länk
Dela kalenderlänk
Alla kalendrar
@@ -2756,5 +2772,7 @@
Nowhere Butik
Nowhere Namninsamling
Nowhere Meddelande
+ Nowhere Drop
Nowhere Konst
+ Nowhere Forum
diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml
index 1656bf9fde..0df7e136ba 100644
--- a/amethyst/src/main/res/values-zh-rCN/strings.xml
+++ b/amethyst/src/main/res/values-zh-rCN/strings.xml
@@ -11,6 +11,9 @@
- 此帖有超过 %1$d 个话题标签
+
+ - 捆绑了 %1$d 个资产
+
事件正在加载或无法在你的中继列表中找到
👀
频道图片
@@ -546,6 +549,11 @@
选择要在个人资料中显示的已收到的徽章。
您还没有收到任何徽章。
图片
+ 应用
+ 应用
+ 来源: %1$s
+ v%1$s
+ 下载
日历
短视频
公共聊天
@@ -1182,6 +1190,8 @@
发布政策
隐私政策
使用条款
+ 儿童安全标准
+ 关于 & 法律
N/A
该中继的错误和通知
中继监视器报告
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 827d73674a..e9897200d3 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1318,6 +1318,8 @@
Posting policy
Privacy Policy
Terms & Conditions
+ Child Safety Standards
+ About & Legal
N/A
Errors and Notices from this Relay
Relay Monitor Reports
diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt
new file mode 100644
index 0000000000..0f17e6ca28
--- /dev/null
+++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.screen.loggedIn.settings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalUriHandler
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+
+@Composable
+fun LegalSettingsSection() {
+ val uriHandler = LocalUriHandler.current
+
+ SettingsSection(R.string.about_legal) {
+ SettingsItem(
+ title = R.string.privacy_policy,
+ icon = MaterialSymbols.Lock,
+ onClick = {
+ runCatching {
+ uriHandler.openUri(
+ "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md",
+ )
+ }
+ },
+ )
+ SettingsDivider()
+ SettingsItem(
+ title = R.string.child_safety_standards,
+ icon = MaterialSymbols.Shield,
+ onClick = {
+ runCatching {
+ uriHandler.openUri(
+ "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards",
+ )
+ }
+ },
+ )
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt
similarity index 80%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt
rename to amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt
index 12cccee79c..fc6edc8f72 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AcceptTerms.kt
+++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/legal/TermsGate.kt
@@ -18,7 +18,7 @@
* 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.screen.loggedOff
+package com.vitorpamplona.amethyst.ui.screen.loggedOff.legal
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Checkbox
@@ -33,9 +33,26 @@ import com.vitorpamplona.amethyst.ui.components.appendLink
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
-fun AcceptTerms(
+fun TermsGate(
checked: Boolean,
- onCheckedChange: ((Boolean) -> Unit)?,
+ onCheckedChange: (Boolean) -> Unit,
+ showError: Boolean,
+) {
+ AcceptTerms(checked = checked, onCheckedChange = onCheckedChange)
+
+ if (showError) {
+ Text(
+ text = stringRes(R.string.acceptance_of_terms_is_required),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+@Composable
+private fun AcceptTerms(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts
index 2dc92ff7a3..cb8d7dc1a8 100644
--- a/commons/build.gradle.kts
+++ b/commons/build.gradle.kts
@@ -44,15 +44,16 @@ kotlin {
}
}
+ // iOS targets — Phase 2 spike. Compile-only for now (no framework binary
+ // configured yet). Reveals which transitive deps need iOS variants and
+ // which commonMain files still reach for platform-only APIs.
+ iosArm64()
+ iosSimulatorArm64()
+
sourceSets {
commonMain {
dependencies {
implementation(project(":quartz"))
- // Audio-rooms ViewModel needs the listener orchestration + audio
- // pipeline types (NestsListener, AudioRoomPlayer, AudioPlayer
- // interface). Concrete OkHttp/Quic/MediaCodec/AudioTrack actuals
- // stay in :nestsClient's platform source sets.
- implementation(project(":nestsClient"))
// Compose Multiplatform
implementation(libs.jetbrains.compose.ui)
@@ -61,13 +62,17 @@ kotlin {
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui.tooling.preview)
- // Lifecycle ViewModel (KMP since 2.8.0)
- implementation(libs.androidx.lifecycle.viewmodel.compose)
+ // Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and
+ // lifecycle-runtime-compose ship iOS variants;
+ // lifecycle-viewmodel-compose (the viewModel() Composable
+ // helper) is Android-only and lives in jvmAndroid below.
+ implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
- // Image loading (Coil 3 - KMP)
+ // Image loading (Coil 3 - KMP). The okhttp network fetcher is
+ // JVM-only and lives in jvmAndroid; iOS will pull coil-ktor
+ // when that target wires its actual.
implementation(libs.coil.compose)
- implementation(libs.coil.okhttp)
// LruCache (KMP-ready)
implementation(libs.androidx.collection)
@@ -75,13 +80,12 @@ kotlin {
// Immutable collections
api(libs.kotlinx.collections.immutable)
+ // JSON for custom-feed definitions (KMP — replaces Jackson
+ // for the one commonMain serializer that was blocking iOS).
+ implementation(libs.kotlinx.serialization.json)
+
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
-
- // Markdown rendering (richtext-commonmark)
- implementation(libs.markdown.commonmark)
- implementation(libs.markdown.ui)
- implementation(libs.markdown.ui.material3)
}
}
@@ -97,6 +101,32 @@ kotlin {
create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
+ // Audio-rooms ViewModel needs the listener orchestration +
+ // audio pipeline types (NestsListener, AudioRoomPlayer,
+ // AudioPlayer interface). The :nestsClient module is
+ // jvmAndroid-only today (its QUIC + Opus + AudioRecord/Track
+ // stacks are JVM-bound), so the dep lives here, not in
+ // commonMain. iOS will need an audio-rooms reroute when
+ // Phase 5 lands.
+ implementation(project(":nestsClient"))
+
+ // Coil's OkHttp network fetcher (JVM-only). iOS will use
+ // coil-ktor when the iOS Compose UI ships.
+ implementation(libs.coil.okhttp)
+
+ // Markdown rendering (richtext-commonmark). The single
+ // consumer (RenderMarkdown.kt) already lives in jvmAndroid.
+ // iOS support pending Phase 3 markdown decision.
+ implementation(libs.markdown.commonmark)
+ implementation(libs.markdown.ui)
+ implementation(libs.markdown.ui.material3)
+
+ // viewModel() Compose helper. AndroidX publishes this
+ // artifact for android/jvmStubs/linuxx64Stubs but not iOS,
+ // so it stays in jvmAndroid until we either swap to the
+ // org.jetbrains.androidx.lifecycle variant or accept a
+ // platform-specific ViewModel access pattern on iOS.
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
}
}
@@ -127,6 +157,14 @@ kotlin {
}
}
+ // iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code.
+ val iosMain =
+ create("iosMain") {
+ dependsOn(commonMain.get())
+ }
+ getByName("iosArm64Main").dependsOn(iosMain)
+ getByName("iosSimulatorArm64Main").dependsOn(iosMain)
+
getByName("androidHostTest") {
dependencies {
implementation(libs.junit)
@@ -150,3 +188,68 @@ compose.resources {
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
generateResClass = always
}
+
+// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale
+// there. Commons gains this gate once FeedDefinitionSerializer.kt has been
+// migrated off Jackson; future commonMain code must not reintroduce JVM-only
+// JSON / HTTP deps.
+val verifyKmpPurity by tasks.registering {
+ group = "verification"
+ description = "Fails if iOS-targeted source sets import JVM-only deps."
+ val checkedDirs =
+ listOf(
+ "src/commonMain", "src/commonTest",
+ "src/appleMain", "src/appleTest",
+ "src/nativeMain", "src/nativeTest",
+ "src/iosMain", "src/iosTest",
+ "src/iosArm64Main", "src/iosArm64Test",
+ "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
+ "src/linuxMain", "src/linuxTest",
+ "src/linuxX64Main", "src/linuxX64Test",
+ "src/macosMain", "src/macosTest",
+ "src/macosArm64Main", "src/macosArm64Test",
+ ).map { layout.projectDirectory.dir(it).asFile }
+ .filter { it.exists() }
+ inputs.files(checkedDirs)
+ doLast {
+ // Each pattern is paired with a short hint so the failure message
+ // points at the canonical KMP replacement.
+ val forbidden =
+ listOf(
+ "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization",
+ "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS",
+ "System.currentTimeMillis" to "use TimeUtils.now()",
+ "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual",
+ "java.util.UUID" to "use kotlin.uuid.Uuid",
+ "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}",
+ "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile",
+ )
+ val offenders =
+ checkedDirs.flatMap { dir ->
+ dir.walkTopDown()
+ .filter { it.isFile && it.extension == "kt" }
+ .flatMap { file ->
+ file.readLines().withIndex().mapNotNull { (idx, line) ->
+ val trimmed = line.trimStart()
+ // Skip KDoc / line-comment lines — those legitimately
+ // mention forbidden names (migration notes, doc refs).
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
+ return@mapNotNull null
+ }
+ forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) ->
+ "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint"
+ }
+ }
+ }
+ }
+ if (offenders.isNotEmpty()) {
+ throw GradleException(
+ "iOS-targeted source sets must not reference JVM-only APIs. " +
+ "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " +
+ offenders.joinToString("\n "),
+ )
+ }
+ }
+}
+
+tasks.named("check").configure { dependsOn(verifyKmpPurity) }
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt
index efa37a1408..ec75e7f01f 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt
@@ -37,7 +37,7 @@ object Base83 {
): String {
val buffer = CharArray(length)
encode(value, length, buffer, 0)
- return String(buffer)
+ return buffer.concatToString()
}
fun encode(
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt
index 529ab2fdac..e1c299749a 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt
@@ -27,6 +27,7 @@ import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
+import kotlin.math.roundToLong
import kotlin.math.withSign
class BlurHashEncoder {
@@ -42,7 +43,7 @@ class BlurHashEncoder {
val quantR = floor(max(0.0, min(18.0, floor(signPow(value[0] / maximumValue, 0.5) * 9 + 9.5))))
val quantG = floor(max(0.0, min(18.0, floor(signPow(value[1] / maximumValue, 0.5) * 9 + 9.5))))
val quantB = floor(max(0.0, min(18.0, floor(signPow(value[2] / maximumValue, 0.5) * 9 + 9.5))))
- return Math.round(quantR * 19 * 19 + quantG * 19 + quantB)
+ return (quantR * 19 * 19 + quantG * 19 + quantB).roundToLong()
}
private fun encodeDC(value: DoubleArray): Long {
@@ -126,7 +127,7 @@ class BlurHashEncoder {
val actualMaximumValue = max(factors, 1, factors.size)
val quantisedMaximumValue = floor(max(0.0, min(82.0, floor(actualMaximumValue * 166 - 0.5))))
maximumValue = (quantisedMaximumValue + 1) / 166
- encode(Math.round(quantisedMaximumValue), 1, hash, 1)
+ encode(quantisedMaximumValue.roundToLong(), 1, hash, 1)
} else {
maximumValue = 1.0
encode(0, 1, hash, 1)
@@ -138,6 +139,6 @@ class BlurHashEncoder {
for (i in 1 until factors.size) {
encode(encodeAC(factors[i], maximumValue), 2, hash, 6 + 2 * (i - 1))
}
- return String(hash)
+ return hash.concatToString()
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt
index 3758f90c6b..75b56ae8fb 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/PeerSessionManager.kt
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.call
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
@@ -46,7 +48,7 @@ class PeerSessionManager(
val pendingIceCandidates: MutableList = mutableListOf(),
)
- private val lock = Any()
+ private val lock = KmpLock()
private val sessions = mutableMapOf()
/** Candidates received before a session exists for the sender. */
@@ -58,7 +60,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
session: PeerSession,
): SessionEntry =
- synchronized(lock) {
+ lock.withLock {
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
val entry = SessionEntry(session)
entry.pendingIceCandidates.addAll(globalPending)
@@ -66,17 +68,17 @@ class PeerSessionManager(
entry
}
- fun getSession(peerPubKey: HexKey): SessionEntry? = synchronized(lock) { sessions[peerPubKey] }
+ fun getSession(peerPubKey: HexKey): SessionEntry? = lock.withLock { sessions[peerPubKey] }
- fun hasSession(peerPubKey: HexKey): Boolean = synchronized(lock) { sessions.containsKey(peerPubKey) }
+ fun hasSession(peerPubKey: HexKey): Boolean = lock.withLock { sessions.containsKey(peerPubKey) }
fun removeSession(peerPubKey: HexKey): SessionEntry? =
- synchronized(lock) {
+ lock.withLock {
globalPendingIce.remove(peerPubKey)
sessions.remove(peerPubKey)
}
- fun allSessionKeys(): Set = synchronized(lock) { sessions.keys.toSet() }
+ fun allSessionKeys(): Set = lock.withLock { sessions.keys.toSet() }
// ---- ICE candidate routing (two-layer buffering) ----
@@ -92,7 +94,7 @@ class PeerSessionManager(
senderPubKey: HexKey,
candidate: IceCandidateData,
): IceRouteAction =
- synchronized(lock) {
+ lock.withLock {
val entry = sessions[senderPubKey]
when {
entry != null && entry.remoteDescriptionSet -> {
@@ -117,21 +119,21 @@ class PeerSessionManager(
* Called after setRemoteDescription succeeds.
*/
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
- val candidates: List
- val entry: SessionEntry
- synchronized(lock) {
- entry = sessions[peerPubKey] ?: return 0
- entry.remoteDescriptionSet = true
- candidates = entry.pendingIceCandidates.toList()
- entry.pendingIceCandidates.clear()
- }
+ val (entry, candidates) =
+ lock.withLock {
+ val entry = sessions[peerPubKey] ?: return@withLock null
+ entry.remoteDescriptionSet = true
+ val candidates = entry.pendingIceCandidates.toList()
+ entry.pendingIceCandidates.clear()
+ entry to candidates
+ } ?: return 0
candidates.forEach { entry.session.addIceCandidate(it) }
return candidates.size
}
- fun globalPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { globalPendingIce[peerPubKey]?.size ?: 0 }
+ fun globalPendingCount(peerPubKey: HexKey): Int = lock.withLock { globalPendingIce[peerPubKey]?.size ?: 0 }
- fun sessionPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
+ fun sessionPendingCount(peerPubKey: HexKey): Int = lock.withLock { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
// ---- Renegotiation glare handling ----
@@ -147,7 +149,7 @@ class PeerSessionManager(
remoteSdpOffer: String,
onAcceptRemote: (SessionEntry) -> Unit,
): GlareResolution {
- val entry = synchronized(lock) { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
+ val entry = lock.withLock { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -184,7 +186,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
sdpAnswer: String,
): AnswerRouteAction {
- val entry = synchronized(lock) { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
+ val entry = lock.withLock { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -199,12 +201,13 @@ class PeerSessionManager(
// ---- Cleanup ----
fun disposeAll() {
- val entries: List
- synchronized(lock) {
- entries = sessions.values.toList()
- sessions.clear()
- globalPendingIce.clear()
- }
+ val entries =
+ lock.withLock {
+ val snapshot = sessions.values.toList()
+ sessions.clear()
+ globalPendingIce.clear()
+ snapshot
+ }
for (entry in entries) {
entry.session.dispose()
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt
index eb7b528af7..e1a6521121 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.commons.chess
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
+
/**
* Global registry of accepted game IDs.
*
@@ -29,28 +32,28 @@ package com.vitorpamplona.amethyst.commons.chess
*/
object AcceptedGamesRegistry {
private val acceptedGameIds = mutableSetOf()
- private val lock = Any()
+ private val lock = KmpLock()
fun markAsAccepted(gameId: String) {
- synchronized(lock) {
+ lock.withLock {
acceptedGameIds.add(gameId)
}
}
fun wasAccepted(gameId: String): Boolean =
- synchronized(lock) {
+ lock.withLock {
acceptedGameIds.contains(gameId)
}
fun clear() {
- synchronized(lock) {
+ lock.withLock {
acceptedGameIds.clear()
}
}
/** Remove old entries - call periodically to prevent memory leak */
fun clearOldEntries(keepGameIds: Set) {
- synchronized(lock) {
+ lock.withLock {
acceptedGameIds.retainAll(keepGameIds)
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt
index 9c9b0e6761..da643df359 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt
@@ -20,16 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.chess
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
import com.vitorpamplona.quartz.utils.Log
+import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import java.util.concurrent.ConcurrentHashMap
/**
* Collects and aggregates Jester chess events for a game from any source.
@@ -67,11 +69,15 @@ class ChessEventCollector(
private val _startEvent = MutableStateFlow(null)
val startEvent: StateFlow = _startEvent.asStateFlow()
- // Move events (deduplicated by event ID)
- private val moves = ConcurrentHashMap()
+ // Move events (deduplicated by event ID). String keys are Comparable, so
+ // LargeCache (ConcurrentSkipListMap on JVM, CacheMap on Apple) works.
+ private val moves = LargeCache()
- // Track all processed event IDs for fast deduplication
- private val processedEventIds = ConcurrentHashMap.newKeySet()
+ // Track all processed event IDs for fast deduplication. A plain HashSet
+ // guarded by KmpLock — simpler than a LargeCache for set-shaped
+ // membership.
+ private val processedEventIdsLock = KmpLock()
+ private val processedEventIds = mutableSetOf()
// Flow that emits when any event is added (for reactive updates)
private val _eventCount = MutableStateFlow(0)
@@ -80,7 +86,11 @@ class ChessEventCollector(
/**
* Check if an event has already been processed.
*/
- fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId)
+ fun hasEvent(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.contains(eventId) }
+
+ private fun markProcessed(eventId: String): Boolean = processedEventIdsLock.withLock { processedEventIds.add(eventId) }
+
+ private fun processedEventCount(): Int = processedEventIdsLock.withLock { processedEventIds.size }
/**
* Add a Jester event for this game.
@@ -89,7 +99,7 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
fun addEvent(event: JesterEvent): Boolean {
- if (processedEventIds.contains(event.id)) {
+ if (hasEvent(event.id)) {
Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" }
return false
}
@@ -129,12 +139,12 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
private fun addStartEvent(event: JesterEvent): Boolean {
- if (processedEventIds.contains(event.id)) return false
+ if (hasEvent(event.id)) return false
if (!event.isStartEvent()) return false
if (event.id != startEventId) return false
if (_startEvent.compareAndSet(null, event)) {
- processedEventIds.add(event.id)
+ markProcessed(event.id)
incrementEventCount()
Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" }
return true
@@ -149,14 +159,15 @@ class ChessEventCollector(
* @return true if the event was added, false if already exists or invalid
*/
private fun addMoveEvent(event: JesterEvent): Boolean {
- if (processedEventIds.contains(event.id)) return false
+ if (hasEvent(event.id)) return false
if (!event.isMoveEvent()) return false
if (event.startEventId() != startEventId) return false
- if (moves.putIfAbsent(event.id, event) == null) {
- processedEventIds.add(event.id)
+ // createIfAbsent returns true iff the entry was added (no prior entry).
+ if (moves.createIfAbsent(event.id) { event }) {
+ markProcessed(event.id)
incrementEventCount()
- Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}" }
+ Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size()}" }
return true
}
return false
@@ -168,7 +179,7 @@ class ChessEventCollector(
fun getEvents(): JesterGameEvents =
JesterGameEvents(
startEvent = _startEvent.value,
- moves = moves.values.toList(),
+ moves = moves.values().toList(),
)
/**
@@ -179,22 +190,22 @@ class ChessEventCollector(
/**
* Check if the game has any moves (indicates game is active).
*/
- fun hasMoves(): Boolean = moves.isNotEmpty()
+ fun hasMoves(): Boolean = !moves.isEmpty()
/**
* Check if the game has ended (has a move with result).
*/
- fun hasEnded(): Boolean = moves.values.any { it.result() != null }
+ fun hasEnded(): Boolean = moves.values().any { it.result() != null }
/**
* Get the number of moves collected.
*/
- fun moveCount(): Int = moves.size
+ fun moveCount(): Int = moves.size()
/**
* Get the latest move (with longest history).
*/
- fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size }
+ fun latestMove(): JesterEvent? = moves.values().maxByOrNull { it.history().size }
/**
* Clear all collected events.
@@ -202,12 +213,12 @@ class ChessEventCollector(
fun clear() {
_startEvent.value = null
moves.clear()
- processedEventIds.clear()
+ processedEventIdsLock.withLock { processedEventIds.clear() }
_eventCount.value = 0
}
private fun incrementEventCount() {
- _eventCount.value = processedEventIds.size
+ _eventCount.value = processedEventCount()
}
}
@@ -218,21 +229,21 @@ class ChessEventCollector(
* such as in a chess lobby or when spectating multiple games.
*/
class ChessEventCollectorManager {
- private val collectors = ConcurrentHashMap()
+ private val collectors = LargeCache()
/**
* Get or create a collector for a game.
*
* @param startEventId The start event ID (game identifier)
*/
- fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) }
+ fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrCreate(startEventId) { ChessEventCollector(it) }
/**
* Get a collector if it exists.
*
* @param startEventId The start event ID (game identifier)
*/
- fun get(startEventId: String): ChessEventCollector? = collectors[startEventId]
+ fun get(startEventId: String): ChessEventCollector? = collectors.get(startEventId)
/**
* Remove a collector for a game.
@@ -244,13 +255,13 @@ class ChessEventCollectorManager {
/**
* Get all active game IDs (start event IDs).
*/
- fun activeGameIds(): Set = collectors.keys.toSet()
+ fun activeGameIds(): Set = collectors.keys()
/**
* Clear all collectors.
*/
fun clear() {
- collectors.values.forEach { it.clear() }
+ collectors.values().forEach { it.clear() }
collectors.clear()
}
@@ -275,8 +286,8 @@ class ChessEventCollectorManager {
return false
}
val collector =
- collectors[startId] ?: run {
- Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" }
+ collectors.get(startId) ?: run {
+ Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys().map { it.take(8) }})" }
return false
}
return collector.addEvent(event)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt
index 8735c8ec09..8d8b2791ac 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.chess
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
+import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -129,18 +132,21 @@ class ChessLobbyLogic(
) {
val state = ChessLobbyState(userPubkey, scope)
+ private val dismissedGameIdsLock = KmpLock()
private val dismissedGameIds: MutableSet =
- java.util.Collections.synchronizedSet(
- dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(),
- )
+ (dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf())
// Track when games were last loaded to prevent duplicate fetches
- // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it)
- private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap()
+ // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it).
+ // String keys are Comparable, so LargeCache works.
+ private val recentlyLoadedGames = LargeCache()
- // Dedup incoming events (same event delivered by multiple relays)
- // Bounded LRU: evict oldest when exceeding capacity
- private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet())
+ // Dedup incoming events (same event delivered by multiple relays).
+ // Bounded LRU: evict oldest when exceeding capacity. mutableSetOf returns
+ // LinkedHashSet on every KMP target, preserving insertion-order iteration
+ // required for LRU eviction below.
+ private val seenEventIdsLock = KmpLock()
+ private val seenEventIds = mutableSetOf()
private val seenEventIdsMax = 500
private val pollingDelegate =
@@ -207,15 +213,21 @@ class ChessLobbyLogic(
if (!event.isStartEvent() && !event.isMoveEvent()) return
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
- synchronized(seenEventIds) {
- if (!seenEventIds.add(event.id)) return
- if (seenEventIds.size > seenEventIdsMax) {
- seenEventIds.iterator().let {
- it.next()
- it.remove()
+ val isNew =
+ seenEventIdsLock.withLock {
+ if (!seenEventIds.add(event.id)) {
+ false
+ } else {
+ if (seenEventIds.size > seenEventIdsMax) {
+ seenEventIds.iterator().let {
+ it.next()
+ it.remove()
+ }
+ }
+ true
}
}
- }
+ if (!isNew) return
Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" }
when {
@@ -438,13 +450,13 @@ class ChessLobbyLogic(
*/
fun handleGameAccepted(startEventId: String) {
// Skip if already loaded or in-flight (multiple move events from same game trigger this)
- val lastLoaded = recentlyLoadedGames[startEventId]
+ val lastLoaded = recentlyLoadedGames.get(startEventId)
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent launches
- recentlyLoadedGames[startEventId] = TimeUtils.now()
+ recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" }
scope.launch(Dispatchers.Default) {
@@ -456,7 +468,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
- recentlyLoadedGames[startEventId] = TimeUtils.now()
+ recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" }
state.addActiveGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
@@ -608,7 +620,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
- recentlyLoadedGames[startEventId] = TimeUtils.now()
+ recentlyLoadedGames.put(startEventId, TimeUtils.now())
state.addSpectatingGame(startEventId, result.liveState)
pollingDelegate.addGameId(startEventId)
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
@@ -641,7 +653,7 @@ class ChessLobbyLogic(
when (result) {
is LoadGameResult.Success -> {
- recentlyLoadedGames[startEventId] = TimeUtils.now()
+ recentlyLoadedGames.put(startEventId, TimeUtils.now())
if (result.liveState.isSpectator) {
state.addSpectatingGame(startEventId, result.liveState)
} else {
@@ -678,13 +690,13 @@ class ChessLobbyLogic(
private suspend fun refreshGame(startEventId: String) {
// Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames)
- val lastLoaded = recentlyLoadedGames[startEventId]
+ val lastLoaded = recentlyLoadedGames.get(startEventId)
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" }
return
}
// Mark immediately to prevent concurrent fetches for the same game
- recentlyLoadedGames[startEventId] = TimeUtils.now()
+ recentlyLoadedGames.put(startEventId, TimeUtils.now())
Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" }
val events = fetcher.fetchGameEvents(startEventId)
@@ -873,16 +885,17 @@ class ChessLobbyLogic(
.map { it.gameId }
.toSet()
+ val dismissedSnapshot = dismissedGameIdsLock.withLock { dismissedGameIds.toSet() }
for (startEventId in newGameIds) {
if (startEventId in completedGameIds) continue
- if (startEventId in dismissedGameIds) continue
+ if (startEventId in dismissedSnapshot) continue
val events = fetcher.fetchGameEvents(startEventId)
val result = ChessGameLoader.loadGame(events, userPubkey)
when (result) {
is LoadGameResult.Success -> {
- recentlyLoadedGames[startEventId] = TimeUtils.now()
+ recentlyLoadedGames.put(startEventId, TimeUtils.now())
// If the discovered game is already finished, send it straight to completed
val gameStatus = result.liveState.gameStatus.value
if (gameStatus is GameStatus.Finished) {
@@ -951,15 +964,23 @@ class ChessLobbyLogic(
fun dismissCompletedGame(gameId: String) {
state.removeCompletedGame(gameId)
- dismissedGameIds.add(gameId)
- dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
+ val snapshot =
+ dismissedGameIdsLock.withLock {
+ dismissedGameIds.add(gameId)
+ dismissedGameIds.toSet()
+ }
+ dismissedStorage?.save(userPubkey, snapshot)
}
fun dismissAllCompletedGames() {
val allIds = state.completedGames.value.map { it.gameId }
state.clearCompletedGames()
- dismissedGameIds.addAll(allIds)
- dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds))
+ val snapshot =
+ dismissedGameIdsLock.withLock {
+ dismissedGameIds.addAll(allIds)
+ dismissedGameIds.toSet()
+ }
+ dismissedStorage?.save(userPubkey, snapshot)
}
/**
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt
index f60b777d32..6323e99b99 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
-import java.util.concurrent.atomic.AtomicLong
/**
* Challenge expiry: 24 hours
@@ -245,9 +244,9 @@ class ChessLobbyState(
private val _selectedGameId = MutableStateFlow(null)
val selectedGameId: StateFlow = _selectedGameId.asStateFlow()
- // State version counter - increments on every game state update
- // UI can observe this to force recomposition when internal state changes
- private val stateVersionCounter = AtomicLong(0)
+ // State version counter — bumped on every game state update so the UI
+ // can force recomposition when internal state changes. MutableStateFlow.update
+ // is itself atomic, so a separate counter is not needed.
private val _stateVersion = MutableStateFlow(0L)
val stateVersion: StateFlow = _stateVersion.asStateFlow()
@@ -383,13 +382,11 @@ class ChessLobbyState(
if (inActiveGames) {
_activeGames.update { it + (gameId to stateToUse) }
- // Increment version to force UI recomposition even if map equals() returns true
- val newVersion = stateVersionCounter.incrementAndGet()
- _stateVersion.value = newVersion
+ // Bump version to force UI recomposition even if map equals() returns true
+ _stateVersion.update { it + 1 }
} else if (inSpectatingGames) {
_spectatingGames.update { it + (gameId to stateToUse) }
- val newVersion = stateVersionCounter.incrementAndGet()
- _stateVersion.value = newVersion
+ _stateVersion.update { it + 1 }
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt
index 703ab3c985..7a1a945712 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt
@@ -20,15 +20,19 @@
*/
package com.vitorpamplona.amethyst.commons.chess
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
-import java.util.concurrent.ConcurrentHashMap
+import kotlin.concurrent.atomics.AtomicInt
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Progress callback for relay fetch operations
@@ -67,6 +71,7 @@ class ChessRelayFetchHelper(
* @param onProgress Optional callback for progress updates per relay
* @return Deduplicated list of events received before timeout/EOSE
*/
+ @OptIn(ExperimentalAtomicApi::class)
suspend fun fetchEvents(
filters: Map>,
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
@@ -74,16 +79,22 @@ class ChessRelayFetchHelper(
): List {
if (filters.isEmpty()) return emptyList()
- val events = ConcurrentHashMap()
+ val events = LargeCache()
val relayCount = filters.keys.size
- val eoseReceived = ConcurrentHashMap.newKeySet()
- val relayEventCounts = ConcurrentHashMap()
+ // Per-relay event counters. Each counter is independently atomic, so
+ // increments don't need to lock the whole map.
+ val relayEventCounts = LargeCache()
+ // Small bounded set; KmpLock-guarded plain set is fine for the size we
+ // expect (one entry per relay in the filter map).
+ val eoseLock = KmpLock()
+ val eoseReceived = mutableSetOf()
val allEose = CompletableDeferred()
val subId = newSubId()
- // Initialize all relays as WAITING
+ // Initialize all relays as WAITING. Eagerly create AtomicInt counters
+ // so onEose / timeout paths can read load() without a put race.
filters.keys.forEach { relay ->
- relayEventCounts[relay] = 0
+ relayEventCounts.getOrCreate(relay) { AtomicInt(0) }
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0))
}
@@ -95,8 +106,8 @@ class ChessRelayFetchHelper(
relay: NormalizedRelayUrl,
forFilters: List?,
) {
- events[event.id] = event
- val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1
+ events.put(event.id, event)
+ val count = relayEventCounts.getOrCreate(relay) { AtomicInt(0) }.addAndFetch(1)
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count))
}
@@ -104,11 +115,15 @@ class ChessRelayFetchHelper(
relay: NormalizedRelayUrl,
forFilters: List?,
) {
- eoseReceived.add(relay)
- val count = relayEventCounts[relay] ?: 0
+ val newEoseSize =
+ eoseLock.withLock {
+ eoseReceived.add(relay)
+ eoseReceived.size
+ }
+ val count = relayEventCounts.get(relay)?.load() ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count))
// Complete when all relays respond
- if (eoseReceived.size >= relayCount) {
+ if (newEoseSize >= relayCount) {
allEose.complete(Unit)
}
}
@@ -119,9 +134,10 @@ class ChessRelayFetchHelper(
// Mark timed-out relays
if (eoseResult == null) {
+ val eoseSnapshot = eoseLock.withLock { eoseReceived.toSet() }
filters.keys.forEach { relay ->
- if (relay !in eoseReceived) {
- val count = relayEventCounts[relay] ?: 0
+ if (relay !in eoseSnapshot) {
+ val count = relayEventCounts.get(relay)?.load() ?: 0
onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count))
}
}
@@ -129,6 +145,6 @@ class ChessRelayFetchHelper(
client.unsubscribe(subId)
- return events.values.toList()
+ return events.values().toList()
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt
index 4513306aca..8704c46398 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.emojicoder
+import com.vitorpamplona.amethyst.commons.util.codePointAtKmp
+import com.vitorpamplona.amethyst.commons.util.codePointCharCount
+import com.vitorpamplona.amethyst.commons.util.codePointToChars
+
object EmojiCoder {
// Variation selectors block https://unicode.org/charts/nameslist/n_FE00.html
// VS1..=VS16
@@ -35,8 +39,8 @@ object EmojiCoder {
Array(256) {
// converts to UTF-16. Always char[2] back
when (it) {
- in 0..15 -> Character.toChars(VARIATION_SELECTOR_START + it)
- in 16..255 -> Character.toChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
+ in 0..15 -> codePointToChars(VARIATION_SELECTOR_START + it)
+ in 16..255 -> codePointToChars(VARIATION_SELECTOR_SUPPLEMENT_START + it - 16)
else -> throw RuntimeException("This should never happen")
}
}
@@ -55,11 +59,11 @@ object EmojiCoder {
fun isCoded(text: String): Boolean {
if (text.length <= 3) return false
- if (!isVariationChar(text.codePointAt(text.length - 2))) {
+ if (!isVariationChar(text.codePointAtKmp(text.length - 2))) {
return false
}
- if (text.length > 4 && !isVariationChar(text.codePointAt(text.length - 4))) {
+ if (text.length > 4 && !isVariationChar(text.codePointAtKmp(text.length - 4))) {
return false
}
@@ -70,7 +74,7 @@ object EmojiCoder {
emoji: String,
text: String,
): String {
- val input = text.toByteArray(Charsets.UTF_8)
+ val input = text.encodeToByteArray()
val out = CharArray(input.size * 2)
var outIdx = 0
for (i in 0 until input.size) {
@@ -78,51 +82,34 @@ object EmojiCoder {
out[outIdx++] = chars[0]
out[outIdx++] = chars[1]
}
- return emoji + String(out)
+ return emoji + out.concatToString()
+ }
+
+ /**
+ * Scans [text] collecting variation-selector bytes until a non-selector is found
+ * after at least one byte has been collected.
+ * Returns the end index (exclusive) in [text] and the collected byte values.
+ */
+ private fun scanVariationBytes(text: String): Pair> {
+ val bytes = mutableListOf()
+ var i = 0
+ while (i < text.length) {
+ val codePoint = text.codePointAtKmp(i)
+ val byte = fromVariationSelector(codePoint)
+ if (byte == null && bytes.isNotEmpty()) break
+ i += codePointCharCount(codePoint) // Advance by correct char count
+ if (byte != null) bytes.add(byte)
+ }
+ return i to bytes
}
fun decode(text: String): String {
- val decoded = mutableListOf()
-
- var i = 0
- while (i < text.length) {
- val codePoint = text.codePointAt(i)
- val byte = fromVariationSelector(codePoint)
-
- if (byte == null && decoded.isNotEmpty()) {
- break
- } else if (byte == null) {
- i += Character.charCount(codePoint) // Advance index by correct number of chars
- continue
- }
-
- decoded.add(byte)
- i += Character.charCount(codePoint) // Advance index by correct number of chars
- }
-
- val decodedArray = ByteArray(decoded.size) { decoded[it].toByte() }
- return String(decodedArray, Charsets.UTF_8)
+ val (_, bytes) = scanVariationBytes(text)
+ return ByteArray(bytes.size) { bytes[it].toByte() }.decodeToString()
}
fun cropToFirstMessage(text: String): String {
- val decoded = mutableListOf()
-
- var i = 0
- while (i < text.length) {
- val codePoint = text.codePointAt(i)
- val byte = fromVariationSelector(codePoint)
-
- if (byte == null && decoded.isNotEmpty()) {
- break
- } else if (byte == null) {
- i += Character.charCount(codePoint) // Advance index by correct number of chars
- continue
- }
-
- decoded.add(byte)
- i += Character.charCount(codePoint) // Advance index by correct number of chars
- }
-
- return text.substring(0, i)
+ val (endIndex, _) = scanVariationBytes(text)
+ return text.substring(0, endIndex)
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt
index 9417caffc7..285ffc2b66 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt
@@ -26,7 +26,10 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
@Stable
class FeedBuilderState(
@@ -66,6 +69,7 @@ class FeedBuilderState(
private val editId: String? = initial?.id
+ @OptIn(ExperimentalUuidApi::class)
fun toDefinition(): FeedDefinition {
val source =
FeedSource.Filter(
@@ -77,17 +81,14 @@ class FeedBuilderState(
kinds = kinds.toImmutableList(),
)
return FeedDefinition(
- id =
- editId ?: java.util.UUID
- .randomUUID()
- .toString(),
+ id = editId ?: Uuid.random().toString(),
name = name,
emoji = emoji,
pinned = false,
pinOrder = Int.MAX_VALUE,
source = source,
refreshMode = refreshMode,
- createdAt = System.currentTimeMillis() / 1000,
+ createdAt = TimeUtils.now(),
)
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt
index 58d72f665b..71a6dab603 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
class FeedDefinitionBuilder {
var name: String = ""
@@ -70,13 +73,11 @@ class FeedDefinitionBuilder {
pinOrder = Int.MAX_VALUE,
source = source ?: error("FeedDefinition requires a source"),
refreshMode = refreshMode,
- createdAt = System.currentTimeMillis() / 1000,
+ createdAt = TimeUtils.now(),
)
- private fun generateId(): String =
- java.util.UUID
- .randomUUID()
- .toString()
+ @OptIn(ExperimentalUuidApi::class)
+ private fun generateId(): String = Uuid.random().toString()
}
class FilterBuilder {
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt
index 397491563b..918cab2458 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt
@@ -20,29 +20,37 @@
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
-import com.fasterxml.jackson.databind.JsonNode
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.node.ArrayNode
-import com.fasterxml.jackson.databind.node.ObjectNode
import kotlinx.collections.immutable.toImmutableList
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.longOrNull
+import kotlinx.serialization.json.put
object FeedDefinitionSerializer {
- private val mapper = ObjectMapper()
+ private val json = Json { ignoreUnknownKeys = true }
fun serializeList(feeds: List): String {
- val array = mapper.createArrayNode()
- feeds.forEach { feed -> array.add(serializeFeed(feed)) }
- return mapper.writeValueAsString(array)
+ val array =
+ buildJsonArray {
+ feeds.forEach { add(serializeFeed(it)) }
+ }
+ return json.encodeToString(JsonArray.serializer(), array)
}
- fun deserializeList(json: String): List {
- if (json.isBlank()) return emptyList()
- val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
- return array.mapNotNull { node -> deserializeFeed(node) }
+ fun deserializeList(jsonString: String): List {
+ if (jsonString.isBlank()) return emptyList()
+ val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList()
+ return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } }
}
- private fun serializeFeed(feed: FeedDefinition): ObjectNode =
- mapper.createObjectNode().apply {
+ private fun serializeFeed(feed: FeedDefinition): JsonObject =
+ buildJsonObject {
put("id", feed.id)
put("name", feed.name)
put("emoji", feed.emoji)
@@ -50,25 +58,25 @@ object FeedDefinitionSerializer {
put("pinOrder", feed.pinOrder)
put("refreshMode", feed.refreshMode.name)
put("createdAt", feed.createdAt)
- set("source", serializeSource(feed.source))
+ put("source", serializeSource(feed.source))
}
- private fun deserializeFeed(node: JsonNode): FeedDefinition? {
- val id = node.get("id")?.asText() ?: return null
- val name = node.get("name")?.asText() ?: return null
- val emoji = node.get("emoji")?.asText() ?: ""
- val pinned = node.get("pinned")?.asBoolean() ?: false
- val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
+ private fun deserializeFeed(node: JsonObject): FeedDefinition? {
+ val id = node.string("id") ?: return null
+ val name = node.string("name") ?: return null
+ val emoji = node.string("emoji") ?: ""
+ val pinned = node.bool("pinned") ?: false
+ val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
val refreshMode =
- node.get("refreshMode")?.asText()?.let {
+ node.string("refreshMode")?.let {
try {
RefreshMode.valueOf(it)
} catch (_: Exception) {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
- val createdAt = node.get("createdAt")?.asLong() ?: 0L
- val source = node.get("source")?.let { deserializeSource(it) } ?: return null
+ val createdAt = node.long("createdAt") ?: 0L
+ val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
id = id,
@@ -82,17 +90,20 @@ object FeedDefinitionSerializer {
)
}
- private fun serializeSource(source: FeedSource): ObjectNode =
- mapper.createObjectNode().apply {
+ private fun serializeSource(source: FeedSource): JsonObject =
+ buildJsonObject {
when (source) {
is FeedSource.Filter -> {
put("type", "filter")
- set("hashtags", mapper.valueToTree(source.hashtags.toList()))
- set("authors", mapper.valueToTree(source.authors.toList()))
- set("relays", mapper.valueToTree(source.relays.toList()))
- set("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
- set("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
- set("kinds", mapper.valueToTree(source.kinds.toList()))
+ put("hashtags", stringArray(source.hashtags))
+ put("authors", stringArray(source.authors))
+ put("relays", stringArray(source.relays))
+ put("excludeAuthors", stringArray(source.excludeAuthors))
+ put("excludeKeywords", stringArray(source.excludeKeywords))
+ put(
+ "kinds",
+ buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } },
+ )
}
is FeedSource.PeopleList -> {
@@ -131,61 +142,73 @@ object FeedDefinitionSerializer {
}
}
- private fun deserializeSource(node: JsonNode): FeedSource? {
- val type = node.get("type")?.asText() ?: return null
+ private fun deserializeSource(node: JsonObject): FeedSource? {
+ val type = node.string("type") ?: return null
return when (type) {
"filter" -> {
FeedSource.Filter(
- hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
- authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
- relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
- excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
- excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
- kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
+ hashtags = node.stringList("hashtags") ?: return null,
+ authors = node.stringList("authors") ?: return null,
+ relays = node.stringList("relays") ?: return null,
+ excludeAuthors = node.stringList("excludeAuthors") ?: return null,
+ excludeKeywords = node.stringList("excludeKeywords") ?: return null,
+ kinds = node.intList("kinds") ?: return null,
)
}
"people_list" -> {
FeedSource.PeopleList(
- kind = node.get("kind")?.asInt() ?: 30000,
- pubkey = node.get("pubkey")?.asText() ?: return null,
- dTag = node.get("dTag")?.asText() ?: return null,
+ kind = node.int("kind") ?: 30000,
+ pubkey = node.string("pubkey") ?: return null,
+ dTag = node.string("dTag") ?: return null,
)
}
"interest_set" -> {
FeedSource.InterestSet(
- kind = node.get("kind")?.asInt() ?: 30015,
- pubkey = node.get("pubkey")?.asText() ?: return null,
- dTag = node.get("dTag")?.asText() ?: return null,
+ kind = node.int("kind") ?: 30015,
+ pubkey = node.string("pubkey") ?: return null,
+ dTag = node.string("dTag") ?: return null,
)
}
"dvm" -> {
FeedSource.DVM(
- kind = node.get("kind")?.asInt() ?: 31990,
- pubkey = node.get("pubkey")?.asText() ?: return null,
- dTag = node.get("dTag")?.asText() ?: return null,
+ kind = node.int("kind") ?: 31990,
+ pubkey = node.string("pubkey") ?: return null,
+ dTag = node.string("dTag") ?: return null,
)
}
"single_relay" -> {
FeedSource.SingleRelay(
- url = node.get("url")?.asText() ?: return null,
+ url = node.string("url") ?: return null,
)
}
- "global" -> {
- FeedSource.Global
- }
-
- "following" -> {
- FeedSource.Following
- }
-
- else -> {
- null
- }
+ "global" -> FeedSource.Global
+ "following" -> FeedSource.Following
+ else -> null
}
}
+
+ private fun stringArray(values: Iterable): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }
+
+ private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
+
+ private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
+
+ private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
+
+ private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
+
+ private fun JsonObject.stringList(key: String) =
+ (this[key] as? JsonArray)
+ ?.map { (it as? JsonPrimitive)?.content.orEmpty() }
+ ?.toImmutableList()
+
+ private fun JsonObject.intList(key: String) =
+ (this[key] as? JsonArray)
+ ?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
+ ?.toImmutableList()
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt
index eac20cda18..960d123b87 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt
@@ -21,13 +21,15 @@
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.WeakReference
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import java.lang.ref.WeakReference
@Stable
abstract class Channel : NotesGatherer {
@@ -41,10 +43,14 @@ abstract class Channel : NotesGatherer {
private var relays = mapOf()
- private var changesFlow: WeakReference>> = WeakReference(null)
+ // Single per-instance lock that previously @Synchronized methods share.
+ // Replaces JVM-only @Synchronized so this class compiles on iOS.
+ private val syncLock = KmpLock()
+
+ private var changesFlow: WeakReference>>? = null
fun changesFlow(): MutableSharedFlow> {
- val current = changesFlow.get()
+ val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow>(0, 10, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -64,23 +70,20 @@ abstract class Channel : NotesGatherer {
abstract fun toBestDisplayName(): String
open fun relays(): Set =
- relays.keys
- .toSortedSet { o1, o2 ->
- val o1Count = relays[o1]?.number ?: 0
- val o2Count = relays[o2]?.number ?: 0
- o2Count.compareTo(o1Count) // descending
- }
+ relays.entries
+ .sortedByDescending { it.value.number }
+ .mapTo(LinkedHashSet(relays.size)) { it.key }
fun updateChannelInfo() {
flowSet?.metadata?.invalidateData()
}
- @Synchronized
- fun addRelaySync(briefInfo: NormalizedRelayUrl) {
- if (briefInfo !in relays) {
- relays = relays + Pair(briefInfo, Counter(1))
+ fun addRelaySync(briefInfo: NormalizedRelayUrl) =
+ syncLock.withLock {
+ if (briefInfo !in relays) {
+ relays = relays + Pair(briefInfo, Counter(1))
+ }
}
- }
fun addRelay(relay: NormalizedRelayUrl) {
val counter = relays[relay]
@@ -107,7 +110,7 @@ abstract class Channel : NotesGatherer {
addRelay(relay)
}
- changesFlow.get()?.tryEmit(ListChange.Addition(note))
+ changesFlow?.get()?.tryEmit(ListChange.Addition(note))
flowSet?.notes?.invalidateData()
}
@@ -122,7 +125,7 @@ abstract class Channel : NotesGatherer {
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
}
- changesFlow.get()?.tryEmit(ListChange.Deletion(note))
+ changesFlow?.get()?.tryEmit(ListChange.Deletion(note))
flowSet?.notes?.invalidateData()
}
@@ -140,7 +143,7 @@ abstract class Channel : NotesGatherer {
toBeRemoved.forEach { notes.remove(it.idHex) }
- changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
+ changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
flowSet?.notes?.invalidateData()
@@ -156,7 +159,7 @@ abstract class Channel : NotesGatherer {
hidden.forEach { notes.remove(it.idHex) }
- changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
+ changesFlow?.get()?.tryEmit(ListChange.SetDeletion(hidden))
flowSet?.notes?.invalidateData()
@@ -165,18 +168,18 @@ abstract class Channel : NotesGatherer {
var flowSet: ChannelFlowSet? = null
- @Synchronized
- fun createOrDestroyFlowSync(create: Boolean) {
- if (create) {
- if (flowSet == null) {
- flowSet = ChannelFlowSet(this)
- }
- } else {
- if (flowSet != null && flowSet?.isInUse() == false) {
- flowSet = null
+ fun createOrDestroyFlowSync(create: Boolean) =
+ syncLock.withLock {
+ if (create) {
+ if (flowSet == null) {
+ flowSet = ChannelFlowSet(this)
+ }
+ } else {
+ if (flowSet != null && flowSet?.isInUse() == false) {
+ flowSet = null
+ }
}
}
- }
fun flow(): ChannelFlowSet {
if (flowSet == null) {
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt
index d250ae7d2a..88b5505385 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt
@@ -24,9 +24,11 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
+import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.commons.util.replace
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -62,17 +64,19 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
+import com.vitorpamplona.quartz.utils.BigDecimal
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny
import com.vitorpamplona.quartz.utils.launchAndWaitAll
+import com.vitorpamplona.quartz.utils.plus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
-import java.math.BigDecimal
+import kotlin.concurrent.Volatile
interface NotesGatherer {
fun removeNote(note: Note)
@@ -105,6 +109,11 @@ class AddressableNote(
open class Note(
val idHex: HexKey,
) : NotesGatherer {
+ // Per-instance lock shared by the previously @Synchronized methods (zap /
+ // onchain-zap / zap-payment / relay-add / flowSet lifecycle). Replaces the
+ // JVM-only @Synchronized annotation so this class compiles on iOS.
+ private val syncLock = KmpLock()
+
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
@@ -154,7 +163,7 @@ open class Note(
var zaps = mapOf()
private set
- var zapsAmount: BigDecimal = BigDecimal.ZERO
+ var zapsAmount: BigDecimal = BigDecimal(0)
/**
* NIP-BC onchain zaps targeting this note.
@@ -165,7 +174,7 @@ open class Note(
* only counts CONFIRMED amounts.
*
* `@Volatile` ensures cross-thread visibility: writes happen on `applicationIOScope`
- * (inside the @Synchronized inner methods) and reads happen on the Compose Main
+ * (inside the syncLock-guarded inner methods) and reads happen on the Compose Main
* thread (gallery recomposition + the reverification driver's `any { … }` check).
*/
@Volatile
@@ -349,7 +358,7 @@ open class Note(
onchainZaps = mapOf()
onchainZapResolved = false
zapPayments = mapOf()
- zapsAmount = BigDecimal.ZERO
+ zapsAmount = BigDecimal(0)
relays = listOf()
if (repliesChanged) flowSet?.replies?.invalidateData()
@@ -421,19 +430,18 @@ open class Note(
}
}
- @Synchronized
private fun innerAddZap(
zapRequest: Note,
zap: Note?,
- ): Boolean {
- if (zaps[zapRequest] == null) {
- zaps = zaps + Pair(zapRequest, zap)
- return true
+ ): Boolean =
+ syncLock.withLock {
+ if (zaps[zapRequest] == null) {
+ zaps = zaps + Pair(zapRequest, zap)
+ return@withLock true
+ }
+ return@withLock false
}
- return false
- }
-
fun addZap(
zapRequest: Note,
zap: Note?,
@@ -447,48 +455,48 @@ open class Note(
}
}
- @Synchronized
private fun innerAddOnchainZap(
txid: String,
entry: OnchainZapEntry,
- ): Boolean {
- val existing = onchainZaps[txid]
- if (existing != null) {
- // Exact structural duplicate (same source Note + same fields) — typical
- // relay echo of the same event. Skip the rewrite to avoid spurious
- // flowSet invalidation.
- if (entry == existing) return false
- // Reject downgrades using the explicit OnchainZapStatus.level (not ordinal)
- // so the upgrade contract survives future enum reordering or insertions.
- if (entry.status.level < existing.status.level) return false
- // Same level: accept only when verifiedSats grows OR the source differs
- // (legitimate alternate signer republishing a split-zap receipt). A strictly
- // smaller verifiedSats is a backend downgrade and we ignore it.
- if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return false
+ ): Boolean =
+ syncLock.withLock {
+ val existing = onchainZaps[txid]
+ if (existing != null) {
+ // Exact structural duplicate (same source Note + same fields) — typical
+ // relay echo of the same event. Skip the rewrite to avoid spurious
+ // flowSet invalidation.
+ if (entry == existing) return@withLock false
+ // Reject downgrades using the explicit OnchainZapStatus.level (not ordinal)
+ // so the upgrade contract survives future enum reordering or insertions.
+ if (entry.status.level < existing.status.level) return@withLock false
+ // Same level: accept only when verifiedSats grows OR the source differs
+ // (legitimate alternate signer republishing a split-zap receipt). A strictly
+ // smaller verifiedSats is a backend downgrade and we ignore it.
+ if (entry.status.level == existing.status.level && entry.verifiedSats < existing.verifiedSats) return@withLock false
+ }
+ onchainZaps = onchainZaps + Pair(txid, entry)
+ return@withLock true
}
- onchainZaps = onchainZaps + Pair(txid, entry)
- return true
- }
- @Synchronized
private fun innerRemoveOnchainZapForSource(
txid: String,
sourceAuthorPubKey: HexKey,
- ): Boolean {
- val existing = onchainZaps[txid] ?: return false
- // Anti-spoof: only remove the entry if its source matches the rejecting event.
- // Otherwise a malicious third party could erase a legitimate CONFIRMED entry
- // by publishing a spoofed kind:8333 with the same txid but a bystander recipient.
- // Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh
- // CONFIRMED replacement should change it; a transient backend hiccup must not
- // wipe a previously-CONFIRMED entry just because some other target on the same
- // event is still UNVERIFIED.
- if (existing.status == OnchainZapStatus.CONFIRMED) return false
- if (existing.source.author?.pubkeyHex == null) return false
- if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return false
- onchainZaps = onchainZaps - txid
- return true
- }
+ ): Boolean =
+ syncLock.withLock {
+ val existing = onchainZaps[txid] ?: return@withLock false
+ // Anti-spoof: only remove the entry if its source matches the rejecting event.
+ // Otherwise a malicious third party could erase a legitimate CONFIRMED entry
+ // by publishing a spoofed kind:8333 with the same txid but a bystander recipient.
+ // Also refuse to remove a CONFIRMED entry — once chain-verified, only a fresh
+ // CONFIRMED replacement should change it; a transient backend hiccup must not
+ // wipe a previously-CONFIRMED entry just because some other target on the same
+ // event is still UNVERIFIED.
+ if (existing.status == OnchainZapStatus.CONFIRMED) return@withLock false
+ if (existing.source.author?.pubkeyHex == null) return@withLock false
+ if (existing.source.author?.pubkeyHex != sourceAuthorPubKey) return@withLock false
+ onchainZaps = onchainZaps - txid
+ return@withLock true
+ }
/**
* Register a NIP-BC onchain zap targeting this note. `source` is the OnchainZapEvent's own
@@ -535,19 +543,18 @@ open class Note(
}
}
- @Synchronized
private fun innerAddZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
- ): Boolean {
- if (zapPayments[zapPaymentRequest] == null) {
- zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
- return true
+ ): Boolean =
+ syncLock.withLock {
+ if (zapPayments[zapPaymentRequest] == null) {
+ zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
+ return@withLock true
+ }
+ return@withLock false
}
- return false
- }
-
fun addZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
@@ -589,12 +596,12 @@ open class Note(
}
}
- @Synchronized
- fun addRelaySync(relay: NormalizedRelayUrl) {
- if (relay !in relays) {
- relays = relays + relay
+ fun addRelaySync(relay: NormalizedRelayUrl) =
+ syncLock.withLock {
+ if (relay !in relays) {
+ relays = relays + relay
+ }
}
- }
fun hasRelay(relay: NormalizedRelayUrl) = relay in relays
@@ -737,13 +744,13 @@ open class Note(
}.flatten()
private fun updateZapTotal() {
- var sumOfAmounts = BigDecimal.ZERO
+ var sumOfAmounts = BigDecimal(0)
// Regular Zap Receipts
zaps.forEach {
val noteEvent = it.value?.event
if (noteEvent is LnZapEvent) {
- sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
+ sumOfAmounts += noteEvent.amount ?: BigDecimal(0)
}
}
@@ -751,7 +758,7 @@ open class Note(
// Unverified/pending entries are tracked but excluded from the total per spec.
onchainZaps.values.forEach { entry ->
if (entry.status == OnchainZapStatus.CONFIRMED) {
- sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats)
+ sumOfAmounts += BigDecimal(entry.verifiedSats)
}
}
@@ -828,7 +835,7 @@ open class Note(
val amount =
try {
LnInvoiceUtil.getAmountInSats(invoice)
- } catch (e: java.lang.Exception) {
+ } catch (e: Exception) {
if (e is CancellationException) throw e
null
}
@@ -879,7 +886,7 @@ open class Note(
.any {
val pledgeValue =
try {
- BigDecimal(it.event?.content)
+ it.event?.content?.let { content -> BigDecimal(content) }
} catch (e: Exception) {
if (e is CancellationException) throw e
null
@@ -889,7 +896,13 @@ open class Note(
pledgeValue != null && it.author == user
}
- fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO }
+ // Manual fold rather than Iterable.sumOf { -> BigDecimal } because that
+ // overload is JVM-only; the common stdlib only ships sumOf for the
+ // primitive numeric types.
+ fun pledgedAmountByOthers(): BigDecimal =
+ replies.fold(BigDecimal(0)) { acc, note ->
+ acc + (note.event?.addedRewardValue() ?: BigDecimal(0))
+ }
fun hasAnyReports(): Boolean {
val dayAgo = TimeUtils.oneDayAgo()
@@ -978,7 +991,7 @@ open class Note(
boosts = emptyList()
reports = emptyMap()
zaps = emptyMap()
- zapsAmount = BigDecimal.ZERO
+ zapsAmount = BigDecimal(0)
}
fun isHiddenFor(accountChoices: LiveHiddenUsers): Boolean {
@@ -1035,18 +1048,18 @@ open class Note(
var flowSet: NoteFlowSet? = null
- @Synchronized
- fun createOrDestroyFlowSync(create: Boolean) {
- if (create) {
- if (flowSet == null) {
- flowSet = NoteFlowSet(this)
- }
- } else {
- if (flowSet != null && flowSet?.isInUse() == false) {
- flowSet = null
+ fun createOrDestroyFlowSync(create: Boolean) =
+ syncLock.withLock {
+ if (create) {
+ if (flowSet == null) {
+ flowSet = NoteFlowSet(this)
+ }
+ } else {
+ if (flowSet != null && flowSet?.isInUse() == false) {
+ flowSet = null
+ }
}
}
- }
fun flow(): NoteFlowSet {
if (flowSet == null) {
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt
index e8cccecac0..ce28f72ffa 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/emphChat/EphemeralChatListState.kt
@@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt
index 1404783958..92b63c5b89 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt
@@ -26,13 +26,15 @@ import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrd
import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.WeakReference
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
-import java.lang.ref.WeakReference
/**
* Represents a Marmot MLS group chat room.
@@ -84,31 +86,34 @@ class MarmotGroupChatroom(
return adminPubkeys.value.any { it in followingKeySet }
}
+ // Per-instance lock shared by previously @Synchronized methods.
+ private val syncLock = KmpLock()
+
// Synthetic note used by list views to represent the group when no
// messages have been received yet. Lazily created and kept stable so
// equality-based feed diffing treats it as the same row across refreshes.
private var cachedPlaceholder: Note? = null
- @Synchronized
- fun placeholderNote(): Note {
- val existing = cachedPlaceholder
- if (existing != null) return existing
- val created =
- Note(placeholderIdHex(nostrGroupId)).apply {
- addGatherer(this@MarmotGroupChatroom)
- }
- cachedPlaceholder = created
- return created
- }
+ fun placeholderNote(): Note =
+ syncLock.withLock {
+ val existing = cachedPlaceholder
+ if (existing != null) return@withLock existing
+ val created =
+ Note(placeholderIdHex(nostrGroupId)).apply {
+ addGatherer(this@MarmotGroupChatroom)
+ }
+ cachedPlaceholder = created
+ created
+ }
companion object {
fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId"
}
- private var changesFlow: WeakReference>> = WeakReference(null)
+ private var changesFlow: WeakReference>>? = null
fun changesFlow(): MutableSharedFlow> {
- val current = changesFlow.get()
+ val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -119,23 +124,23 @@ class MarmotGroupChatroom(
removeMessageSync(note)
}
- @Synchronized
- fun addMessageSync(msg: Note): Boolean {
- if (msg !in messages) {
- messages = messages + msg
- msg.addGatherer(this)
+ fun addMessageSync(msg: Note): Boolean =
+ syncLock.withLock {
+ if (msg !in messages) {
+ messages = messages + msg
+ msg.addGatherer(this)
- val createdAt = msg.createdAt() ?: 0L
- if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
- newestMessage = msg
+ val createdAt = msg.createdAt() ?: 0L
+ if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
+ newestMessage = msg
+ }
+
+ unreadCount.value += 1
+ changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
+ return@withLock true
}
-
- unreadCount.value += 1
- changesFlow.get()?.tryEmit(ListChange.Addition(msg))
- return true
+ return@withLock false
}
- return false
- }
/**
* Add a message that is being restored from persistent storage on app
@@ -143,38 +148,38 @@ class MarmotGroupChatroom(
* count — restored messages were already seen by the user in a previous
* session.
*/
- @Synchronized
- fun restoreMessageSync(msg: Note): Boolean {
- if (msg !in messages) {
- messages = messages + msg
- msg.addGatherer(this)
+ fun restoreMessageSync(msg: Note): Boolean =
+ syncLock.withLock {
+ if (msg !in messages) {
+ messages = messages + msg
+ msg.addGatherer(this)
- val createdAt = msg.createdAt() ?: 0L
- if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
- newestMessage = msg
+ val createdAt = msg.createdAt() ?: 0L
+ if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
+ newestMessage = msg
+ }
+
+ changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
+ return@withLock true
}
-
- changesFlow.get()?.tryEmit(ListChange.Addition(msg))
- return true
+ return@withLock false
}
- return false
- }
- @Synchronized
- fun removeMessageSync(msg: Note): Boolean {
- if (msg in messages) {
- messages = messages - msg
- msg.removeGatherer(this)
+ fun removeMessageSync(msg: Note): Boolean =
+ syncLock.withLock {
+ if (msg in messages) {
+ messages = messages - msg
+ msg.removeGatherer(this)
- if (msg == newestMessage) {
- newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
+ if (msg == newestMessage) {
+ newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
+ }
+
+ changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
+ return@withLock true
}
-
- changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
- return true
+ return@withLock false
}
- return false
- }
fun markAsRead() {
unreadCount.value = 0
@@ -199,7 +204,7 @@ class MarmotGroupChatroom(
val toRemove = messages.minus(toKeep)
messages = toKeep
- changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove))
+ changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove))
return toRemove
}
@@ -209,14 +214,14 @@ class MarmotGroupChatroom(
* decrypted inner notes become eligible for GC out of LocalCache (which
* holds them weakly).
*/
- @Synchronized
- fun clearAllMessagesSync(): Set {
- val toRemove = messages
- if (toRemove.isEmpty()) return toRemove
- messages = emptySet()
- newestMessage = null
- unreadCount.value = 0
- changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove))
- return toRemove
- }
+ fun clearAllMessagesSync(): Set =
+ syncLock.withLock {
+ val toRemove = messages
+ if (toRemove.isEmpty()) return@withLock toRemove
+ messages = emptySet()
+ newestMessage = null
+ unreadCount.value = 0
+ changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove))
+ toRemove
+ }
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt
index 6717126d48..8622d1b5f7 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt
@@ -21,10 +21,13 @@
package com.vitorpamplona.amethyst.commons.model.nip01Core
import androidx.compose.runtime.Stable
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.WeakReference
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import kotlinx.coroutines.flow.MutableStateFlow
-import java.lang.ref.WeakReference
+import kotlin.concurrent.Volatile
@Stable
data class RelayInfo(
@@ -52,10 +55,16 @@ val DefaultOrder =
@Stable
class UserRelaysCache {
var data: Map = mapOf()
+
+ // @Volatile is required for the double-checked locking in flow() to be
+ // safe on weak-memory platforms (K/N + ARM). Without it, the outer
+ // fast-path read could observe a partially-published WeakReference.
+ @Volatile
private var flow: WeakReference>? = null
+ private val flowLock = KmpLock()
fun flow() =
- flow?.get() ?: synchronized(this) {
+ flow?.get() ?: flowLock.withLock {
flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) }
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt
index ec68c611c8..d15c13f040 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt
@@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt
index d9c151b018..f34b9a0792 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt
@@ -58,7 +58,7 @@ object LongFormPublishAction {
throw IllegalStateException("Cannot publish: signer is not writeable")
}
- if (content.toByteArray().size > MAX_CONTENT_BYTES) {
+ if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) {
throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes")
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt
index 3103bc76a7..2c59434441 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt
@@ -32,6 +32,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt
index 2fc9bfbf34..f8f77d36c9 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt
index 8b56b6e2ba..726c677e88 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/BookmarkListState.kt
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt
index 67f499deba..5da941a712 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/OldBookmarkListState.kt
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt
index f21bf18a7f..557e3fd0f2 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip65RelayList/Nip65RelayListState.kt
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt
index b5af6e94e6..947fa61ab8 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/CreatedAtComparator.kt
@@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.commons.model.observables
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.quartz.nip01Core.core.Event
val CreatedAtIdHexComparator: Comparator =
Comparator { note1, note2 ->
// Sort by created_at and idhex, but define uniqueness by reference
if (note1 === note2) return@Comparator 0
- if (note1 == null) return@Comparator 1 // null is greater, moves to last
- if (note2 == null) return@Comparator -1 // null is greater, moves to last
-
val createdAt1 = note1.createdAt()
val createdAt2 = note2.createdAt()
@@ -49,42 +47,31 @@ val CreatedAtIdHexComparator: Comparator =
}
}
+private inline fun compareByCreatedAt(
+ a: T,
+ b: T,
+ eventOf: (T) -> Event?,
+): Int {
+ val firstEvent = eventOf(a)
+ val secondEvent = eventOf(b)
+ return when {
+ firstEvent == null && secondEvent == null -> 0
+ firstEvent == null -> 1
+ secondEvent == null -> -1
+ else -> firstEvent.createdAt.compareTo(secondEvent.createdAt)
+ }
+}
+
object CreatedAtComparator : Comparator {
override fun compare(
- first: Note?,
- second: Note?,
- ): Int {
- val firstEvent = first?.event
- val secondEvent = second?.event
-
- return if (firstEvent == null && secondEvent == null) {
- 0
- } else if (firstEvent == null) {
- 1
- } else if (secondEvent == null) {
- -1
- } else {
- firstEvent.createdAt.compareTo(secondEvent.createdAt)
- }
- }
+ a: Note,
+ b: Note,
+ ): Int = compareByCreatedAt(a, b) { it.event }
}
object CreatedAtComparatorAddresses : Comparator {
override fun compare(
- first: AddressableNote?,
- second: AddressableNote?,
- ): Int {
- val firstEvent = first?.event
- val secondEvent = second?.event
-
- return if (firstEvent == null && secondEvent == null) {
- 0
- } else if (firstEvent == null) {
- 1
- } else if (secondEvent == null) {
- -1
- } else {
- firstEvent.createdAt.compareTo(secondEvent.createdAt)
- }
- }
+ a: AddressableNote,
+ b: AddressableNote,
+ ): Int = compareByCreatedAt(a, b) { it.event }
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt
index 0b732db7cf..43678ca38a 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt
@@ -26,6 +26,9 @@ import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NotesGatherer
import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.WeakReference
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
@@ -33,7 +36,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import java.lang.ref.WeakReference
@Stable
class Chatroom : NotesGatherer {
@@ -44,10 +46,13 @@ class Chatroom : NotesGatherer {
var ownerSentMessage: Boolean = false
var newestMessage: Note? = null
- private var changesFlow: WeakReference>> = WeakReference(null)
+ // Per-instance lock shared by previously @Synchronized methods.
+ private val syncLock = KmpLock()
+
+ private var changesFlow: WeakReference>>? = null
fun changesFlow(): MutableSharedFlow> {
- val current = changesFlow.get()
+ val current = changesFlow?.get()
if (current != null) return current
val new = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
@@ -58,68 +63,68 @@ class Chatroom : NotesGatherer {
removeMessageSync(note)
}
- @Synchronized
- fun addMessageSync(msg: Note): Boolean {
- if (msg !in messages) {
- messages = messages + msg
- msg.addGatherer(this)
+ fun addMessageSync(msg: Note): Boolean =
+ syncLock.withLock {
+ if (msg !in messages) {
+ messages = messages + msg
+ msg.addGatherer(this)
- msg.author?.let { author ->
- if (author !in activeSenders) {
- activeSenders + author
- }
- }
-
- val createdAt = msg.createdAt() ?: 0L
- if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
- newestMessage = msg
- }
-
- val newSubject = msg.event?.subject()
-
- if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) {
- subject.tryEmit(newSubject)
- subjectCreatedAt = msg.createdAt()
- }
-
- changesFlow.get()?.tryEmit(ListChange.Addition(msg))
-
- return true
- }
- return false
- }
-
- @Synchronized
- fun removeMessageSync(msg: Note): Boolean {
- if (msg in messages) {
- messages = messages - msg
- msg.removeGatherer(this)
-
- if (msg == newestMessage) {
- newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
- }
-
- if (msg.event?.subject() == subject.value) {
- messages
- .maxByOrNull {
- val noteEvent = it.event
- if (noteEvent?.subject() != null) {
- noteEvent.createdAt
- } else {
- 0
- }
- }?.let {
- subject.tryEmit(it.event?.subject())
- subjectCreatedAt = it.createdAt()
+ msg.author?.let { author ->
+ if (author !in activeSenders) {
+ activeSenders + author
}
+ }
+
+ val createdAt = msg.createdAt() ?: 0L
+ if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
+ newestMessage = msg
+ }
+
+ val newSubject = msg.event?.subject()
+
+ if (newSubject != null && (msg.createdAt() ?: 0L) > (subjectCreatedAt ?: 0)) {
+ subject.tryEmit(newSubject)
+ subjectCreatedAt = msg.createdAt()
+ }
+
+ changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
+
+ return@withLock true
}
-
- changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
-
- return true
+ return@withLock false
+ }
+
+ fun removeMessageSync(msg: Note): Boolean =
+ syncLock.withLock {
+ if (msg in messages) {
+ messages = messages - msg
+ msg.removeGatherer(this)
+
+ if (msg == newestMessage) {
+ newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L }
+ }
+
+ if (msg.event?.subject() == subject.value) {
+ messages
+ .maxByOrNull {
+ val noteEvent = it.event
+ if (noteEvent?.subject() != null) {
+ noteEvent.createdAt
+ } else {
+ 0
+ }
+ }?.let {
+ subject.tryEmit(it.event?.subject())
+ subjectCreatedAt = it.createdAt()
+ }
+ }
+
+ changesFlow?.get()?.tryEmit(ListChange.Deletion(msg))
+
+ return@withLock true
+ }
+ return@withLock false
}
- return false
- }
fun senderIntersects(keySet: Set): Boolean = activeSenders.any { it.pubkeyHex in keySet }
@@ -138,7 +143,7 @@ class Chatroom : NotesGatherer {
val toRemove = messages.minus(toKeep)
messages = toKeep
- changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove))
+ changesFlow?.get()?.tryEmit(ListChange.SetDeletion(toRemove))
return toRemove
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt
index c8970683e7..295f945397 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.EOSERelayList
import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt
index 06851efead..22ffa50d4e 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt
@@ -21,7 +21,8 @@
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
import androidx.compose.runtime.Stable
-import java.util.concurrent.ConcurrentHashMap
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
/**
* This allows composables to directly register their queries
@@ -32,13 +33,16 @@ import java.util.concurrent.ConcurrentHashMap
abstract class ComposeSubscriptionManager :
ComposeSubscriptionManagerControls,
Subscribable {
- private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap()
+ // T has no Comparable bound — see the note in MutableComposeSubscriptionManager
+ // for why LargeCache isn't used here.
+ private val lock = KmpLock()
+ private val composeSubscriptions = mutableMapOf()
// This is called by main. Keep it really fast.
override fun subscribe(query: T?) {
if (query == null) return
- composeSubscriptions.put(query, query)
+ lock.withLock { composeSubscriptions[query] = query }
invalidateKeys()
}
@@ -47,7 +51,7 @@ abstract class ComposeSubscriptionManager :
override fun unsubscribe(query: T?) {
if (query == null) return
- composeSubscriptions.remove(query)
+ lock.withLock { composeSubscriptions.remove(query) }
invalidateKeys()
}
@@ -55,8 +59,8 @@ abstract class ComposeSubscriptionManager :
override fun subscribe(query: List) {
if (query.isEmpty()) return
- query.forEach {
- composeSubscriptions.put(it, it)
+ lock.withLock {
+ query.forEach { composeSubscriptions[it] = it }
}
invalidateKeys()
@@ -66,14 +70,14 @@ abstract class ComposeSubscriptionManager :
override fun unsubscribe(query: List) {
if (query.isEmpty()) return
- query.forEach {
- composeSubscriptions.remove(it)
+ lock.withLock {
+ query.forEach { composeSubscriptions.remove(it) }
}
invalidateKeys()
}
- fun allKeys() = composeSubscriptions.keys
+ fun allKeys(): Set = lock.withLock { composeSubscriptions.keys.toSet() }
}
interface Subscribable {
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt
index da8e82276a..97eabca106 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/MutableComposeSubscriptionManager.kt
@@ -21,12 +21,13 @@
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
import androidx.compose.runtime.Stable
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
-import java.util.concurrent.ConcurrentHashMap
/**
* This allows composables to directly register their queries
@@ -41,19 +42,26 @@ import java.util.concurrent.ConcurrentHashMap
abstract class MutableComposeSubscriptionManager(
val scope: CoroutineScope,
) : ComposeSubscriptionManagerControls {
- private var composeSubscriptions: ConcurrentHashMap = ConcurrentHashMap()
+ // T is generic and not required to be Comparable, so this can't use
+ // LargeCache (ConcurrentSkipListMap-backed on JVM). A plain map guarded by
+ // KmpLock gives the same atomicity guarantees ConcurrentHashMap did
+ // previously, with no Comparable requirement.
+ private val lock = KmpLock()
+ private val composeSubscriptions = mutableMapOf()
// This is called by main. Keep it really fast.
fun subscribe(query: T?) {
if (query == null) return
- composeSubscriptions[query]?.cancel()
- composeSubscriptions[query] =
- scope.launch {
- query.flow().collectLatest {
- invalidateKeys()
+ lock.withLock {
+ composeSubscriptions[query]?.cancel()
+ composeSubscriptions[query] =
+ scope.launch {
+ query.flow().collectLatest {
+ invalidateKeys()
+ }
}
- }
+ }
invalidateKeys()
}
@@ -62,16 +70,19 @@ abstract class MutableComposeSubscriptionManager(
fun unsubscribe(query: T?) {
if (query == null) return
- composeSubscriptions[query]?.cancel()
- composeSubscriptions.remove(query)
+ lock.withLock {
+ composeSubscriptions[query]?.cancel()
+ composeSubscriptions.remove(query)
+ }
invalidateKeys()
}
- fun allKeys() = composeSubscriptions.keys
+ fun allKeys(): Set = lock.withLock { composeSubscriptions.keys.toSet() }
fun forEachSubscriber(action: (T) -> Unit) {
- composeSubscriptions.keys.forEach(action)
+ val snapshot = lock.withLock { composeSubscriptions.keys.toList() }
+ snapshot.forEach(action)
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt
index 427d341953..a16d3734e7 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
abstract class BaseEoseManager(
val client: INostrClient,
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt
index 7cf60cb2dd..71a9288804 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSECache.kt
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.relays
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
@@ -33,14 +35,14 @@ open class EOSECache(
private val maxSize: Int = 200,
) {
private val cache = linkedMapOf()
- private val lock = Any()
+ private val lock = KmpLock()
fun addOrUpdate(
key: K,
relayUrl: NormalizedRelayUrl,
time: Long,
) {
- synchronized(lock) {
+ lock.withLock {
val relayList = cache[key]
if (relayList == null) {
// Evict oldest if at capacity
@@ -57,7 +59,7 @@ open class EOSECache(
}
fun since(key: K): SincePerRelayMap? =
- synchronized(lock) {
+ lock.withLock {
cache[key]?.relayList?.toMutableMap()
}
@@ -68,18 +70,18 @@ open class EOSECache(
) = addOrUpdate(key, relayUrl, time)
fun remove(key: K) {
- synchronized(lock) {
+ lock.withLock {
cache.remove(key)
}
}
fun clear() {
- synchronized(lock) {
+ lock.withLock {
cache.clear()
}
}
- fun size(): Int = synchronized(lock) { cache.size }
+ fun size(): Int = lock.withLock { cache.size }
}
/**
@@ -91,7 +93,7 @@ open class EOSETwoLevelCache(
private val innerMaxSize: Int = 200,
) {
private val cache = linkedMapOf>()
- private val lock = Any()
+ private val lock = KmpLock()
fun addOrUpdate(
outerKey: K1,
@@ -99,7 +101,7 @@ open class EOSETwoLevelCache(
relayUrl: NormalizedRelayUrl,
time: Long,
) {
- synchronized(lock) {
+ lock.withLock {
val innerCache = cache[outerKey]
if (innerCache == null) {
// Evict oldest if at capacity
@@ -119,7 +121,7 @@ open class EOSETwoLevelCache(
outerKey: K1,
innerKey: K2,
): SincePerRelayMap? =
- synchronized(lock) {
+ lock.withLock {
cache[outerKey]?.since(innerKey)
}
@@ -131,13 +133,13 @@ open class EOSETwoLevelCache(
) = addOrUpdate(outerKey, innerKey, relayUrl, time)
fun removeOuter(key: K1) {
- synchronized(lock) {
+ lock.withLock {
cache.remove(key)
}
}
fun clear() {
- synchronized(lock) {
+ lock.withLock {
cache.clear()
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt
index 342f0b6205..09a67302dc 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt
@@ -20,21 +20,20 @@
*/
package com.vitorpamplona.amethyst.commons.richtext
-import java.util.Base64
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
object Base64Image {
val pattern = Patterns.BASE64_IMAGE
fun isBase64(content: String): Boolean = Patterns.BASE64_IMAGE.matches(content)
+ @OptIn(ExperimentalEncodingApi::class)
fun parse(content: String): ByteArray {
- val matcher = pattern.find(content)
- if (matcher != null) {
- val base64String = matcher.groups[2]?.value
- val byteArray = Base64.getDecoder().decode(base64String)
- return byteArray
- }
-
- throw Exception("Unable to convert base64 to image $content")
+ val match = pattern.find(content) ?: throw Exception("Unable to convert base64 to image $content")
+ val base64String =
+ match.groups[2]?.value
+ ?: throw Exception("Unable to convert base64 to image $content")
+ return Base64.decode(base64String)
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt
index 1d2a534741..be472293f2 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt
@@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
-import java.io.File
+
+// URL-based media content models (KMP). The locally-cached variants
+// (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) live in
+// MediaLocalContent.kt under the jvmAndroid source set because they depend
+// on java.io.File.
@Immutable
abstract class BaseMediaContent(
@@ -123,44 +127,3 @@ class EncryptedMediaUrlVideo(
thumbhash: String? = null,
authorPubKey: String? = null,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey)
-
-@Immutable
-abstract class MediaPreloadedContent(
- val localFile: File?,
- description: String? = null,
- val mimeType: String? = null,
- val isVerified: Boolean? = null,
- dim: DimensionTag? = null,
- blurhash: String? = null,
- val uri: String,
- val id: String? = null,
- thumbhash: String? = null,
-) : BaseMediaContent(description, dim, blurhash, thumbhash) {
- fun localFileExists() = localFile != null && localFile.exists()
-}
-
-@Immutable
-class MediaLocalImage(
- localFile: File?,
- mimeType: String? = null,
- description: String? = null,
- dim: DimensionTag? = null,
- blurhash: String? = null,
- isVerified: Boolean? = null,
- uri: String,
- thumbhash: String? = null,
-) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
-
-@Immutable
-class MediaLocalVideo(
- localFile: File?,
- mimeType: String? = null,
- description: String? = null,
- dim: DimensionTag? = null,
- blurhash: String? = null,
- isVerified: Boolean? = null,
- uri: String,
- val artworkUri: String? = null,
- val authorName: String? = null,
- thumbhash: String? = null,
-) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt
index 05399c493e..b8f8736716 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
+import com.vitorpamplona.amethyst.commons.util.isValidUrl
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -40,9 +41,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toPersistentList
-import java.net.MalformedURLException
-import java.net.URI
-import java.net.URISyntaxException
import kotlin.coroutines.cancellation.CancellationException
class RichTextParser {
@@ -489,21 +487,7 @@ class RichTextParser {
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
}
- fun isValidURL(url: String?): Boolean =
- try {
- if (url != null) {
- URI(url).toURL()
- true
- } else {
- false
- }
- } catch (e: MalformedURLException) {
- false
- } catch (e: URISyntaxException) {
- false
- } catch (e: IllegalArgumentException) {
- false
- }
+ fun isValidURL(url: String?): Boolean = isValidUrl(url)
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt
index 66edbae1e3..97621650b2 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt
@@ -168,7 +168,7 @@ class RobohashAssembler {
Hex.decode(msg)
} else {
Log.w("Robohash") { "$msg is not a hex" }
- sha256(msg.toByteArray())
+ sha256(msg.encodeToByteArray())
}
val bgColor = SolidColor(bytesToColor(hash[0], hash[1], hash[2], isLightTheme))
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt
index 8a843955ae..24cc954eb5 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt
@@ -20,15 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.search
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
+
class EventDeduplicator {
- private val lock = Any()
+ private val lock = KmpLock()
private val seenIds = mutableSetOf()
- fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) }
+ fun tryAdd(id: String): Boolean = lock.withLock { seenIds.add(id) }
- fun contains(id: String): Boolean = synchronized(lock) { id in seenIds }
+ fun contains(id: String): Boolean = lock.withLock { id in seenIds }
- fun clear() = synchronized(lock) { seenIds.clear() }
+ fun clear() = lock.withLock { seenIds.clear() }
- val size: Int get() = synchronized(lock) { seenIds.size }
+ val size: Int get() = lock.withLock { seenIds.size }
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt
index cad35cbc4c..79ff14f824 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/BundledUpdate.kt
@@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt
index 30bc6fd3b0..a73db81302 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.commons.thumbhash
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.PI
@@ -50,14 +52,14 @@ object ThumbHashDecoder {
// (size, components) pairs across the entire app lifetime, each table is
// a few KB, so the memory ceiling is tiny.
private val cosineCache = HashMap()
- private val cosineCacheLock = Any()
+ private val cosineCacheLock = KmpLock()
/**
* Clear the cosine table cache. Tables are tiny but callers under memory
* pressure can release them; they will be recomputed on demand.
*/
fun clearCache() {
- synchronized(cosineCacheLock) { cosineCache.clear() }
+ cosineCacheLock.withLock { cosineCache.clear() }
}
private fun cosTable(
@@ -65,9 +67,8 @@ object ThumbHashDecoder {
components: Int,
): DoubleArray {
val key = (size.toLong() shl 32) or components.toLong()
- synchronized(cosineCacheLock) {
- cosineCache[key]?.let { return it }
- }
+ val cached = cosineCacheLock.withLock { cosineCache[key] }
+ if (cached != null) return cached
val table = DoubleArray(size * components)
val piOverSize = PI / size
for (i in 0 until size) {
@@ -77,10 +78,7 @@ object ThumbHashDecoder {
table[rowOffset + c] = cos(phase * c)
}
}
- synchronized(cosineCacheLock) {
- cosineCache.getOrPut(key) { table }
- }
- return table
+ return cosineCacheLock.withLock { cosineCache.getOrPut(key) { table } }
}
/**
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt
index 61d08af462..0ca8bb4748 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt
@@ -175,7 +175,7 @@ fun FeedErrorState(
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
- val formattedMessage = stringResource(Res.string.error_loading_feed).format(errorMessage)
+ val formattedMessage = stringResource(Res.string.error_loading_feed, errorMessage)
ErrorState(
message = formattedMessage,
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt
index 996e8d052f..2071bbd55c 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt
@@ -35,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt
index 743cc70398..d76efe5b99 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt
@@ -26,8 +26,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
-import java.util.concurrent.atomic.AtomicInteger
+import kotlin.concurrent.atomics.AtomicInt
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.cancellation.CancellationException
sealed class SigningOpState {
@@ -44,26 +46,27 @@ sealed class SigningOpState {
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
* Observe [globalState] from a screen-level composable to show a persistent status bar.
*/
+@OptIn(ExperimentalAtomicApi::class)
object GlobalSigningStatus {
var globalState by mutableStateOf(SigningOpState.Idle)
private set
- private val activeCount = AtomicInteger(0)
+ private val activeCount = AtomicInt(0)
fun onPending() {
- activeCount.incrementAndGet()
+ activeCount.addAndFetch(1)
globalState = SigningOpState.Pending
}
fun onIdle() {
- if (activeCount.decrementAndGet() <= 0) {
- activeCount.set(0)
+ if (activeCount.addAndFetch(-1) <= 0) {
+ activeCount.store(0)
globalState = SigningOpState.Idle
}
}
fun onError(message: String) {
- activeCount.decrementAndGet()
+ activeCount.addAndFetch(-1)
globalState = SigningOpState.Error(message)
}
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt
new file mode 100644
index 0000000000..41df19f0d7
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/CodePoints.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.util
+
+// KMP code-point helpers — replacements for `java.lang.Character.*` and the
+// JVM-only `String.codePointAt(Int)` / `String.offsetByCodePoints(Int, Int)`
+// extensions. UTF-16 surrogate-pair aware so emoji and other supplementary
+// code points are handled correctly on Native targets.
+
+/** Number of UTF-16 code units (1 or 2) needed for [codePoint]. */
+fun codePointCharCount(codePoint: Int): Int = if (codePoint >= 0x10000) 2 else 1
+
+/**
+ * Encode [codePoint] as UTF-16. Returns a 1-char array for BMP code points and
+ * a high+low surrogate pair for supplementary code points (≥ U+10000).
+ */
+fun codePointToChars(codePoint: Int): CharArray =
+ if (codePoint < 0x10000) {
+ charArrayOf(codePoint.toChar())
+ } else {
+ val offset = codePoint - 0x10000
+ charArrayOf(
+ (0xD800 + (offset ushr 10)).toChar(),
+ (0xDC00 + (offset and 0x3FF)).toChar(),
+ )
+ }
+
+/**
+ * Decode the Unicode code point that starts at [index]. Mirrors
+ * `java.lang.Character.codePointAt(CharSequence, int)`: returns the
+ * supplementary code point when [index] points at a well-formed high surrogate
+ * followed by a low surrogate; otherwise returns the raw `Char.code`.
+ */
+fun String.codePointAtKmp(index: Int): Int {
+ val high = this[index]
+ if (high.isHighSurrogate() && index + 1 < length) {
+ val low = this[index + 1]
+ if (low.isLowSurrogate()) {
+ return 0x10000 + ((high.code - 0xD800) shl 10) + (low.code - 0xDC00)
+ }
+ }
+ return high.code
+}
+
+/**
+ * Return the index that is [codePointOffset] code points away from [index].
+ * Negative offsets are not supported (matches the only call site usage today).
+ */
+fun String.offsetByCodePointsKmp(
+ index: Int,
+ codePointOffset: Int,
+): Int {
+ var i = index
+ repeat(codePointOffset) {
+ i += codePointCharCount(codePointAtKmp(i))
+ }
+ return i
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt
index e383b1fe45..d225d89c02 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.util
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
-fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
+fun String.isUTF16Char(pos: Int): Boolean = codePointCharCount(this.codePointAtKmp(pos)) == 2
fun String.firstFullCharOld(): String {
return when (this.length) {
@@ -64,11 +64,11 @@ fun String.firstFullChar(): String {
var i = 0
while (i < this.length) {
- codePoint = codePointAt(i)
+ codePoint = codePointAtKmp(i)
// Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) {
- next = offsetByCodePoints(i, 1)
+ next = offsetByCodePointsKmp(i, 1)
start = next
} else {
// If join, searches for the next char
@@ -78,7 +78,7 @@ fun String.firstFullChar(): String {
} else {
// stops when two chars are not joined together
if (previousCharLength > 0 && !isInJoin) {
- if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
+ if (codePointCharCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
@@ -91,7 +91,7 @@ fun String.firstFullChar(): String {
}
// next char to evaluate
- next = offsetByCodePoints(i, 1)
+ next = offsetByCodePointsKmp(i, 1)
previousCharLength += (next - i)
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt
new file mode 100644
index 0000000000..ad2622de74
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.util
+
+/**
+ * KMP-friendly reentrant lock. JVM/Android map to `java.util.concurrent.locks.ReentrantLock`;
+ * iOS maps to `NSRecursiveLock`.
+ *
+ * Use [withLock] in preference to manual lock/unlock — it guarantees release on
+ * exception. The class is reentrant on every platform, so a thread that already
+ * holds the lock can re-enter without deadlocking.
+ */
+expect class KmpLock() {
+ fun lock()
+
+ fun unlock()
+}
+
+inline fun KmpLock.withLock(block: () -> T): T {
+ lock()
+ try {
+ return block()
+ } finally {
+ unlock()
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt
new file mode 100644
index 0000000000..9a63884678
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.util
+
+/**
+ * True iff [url] is a syntactically valid absolute URL with a recognized
+ * scheme. JVM/Android delegate to `java.net.URI(url).toURL()`; the iOS actual
+ * will use `NSURL(string:)` when that target is added.
+ *
+ * Called per-URL during feed render — actuals should keep this fast and
+ * allocation-light.
+ */
+expect fun isValidUrl(url: String?): Boolean
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt
new file mode 100644
index 0000000000..1521301189
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.util
+
+/**
+ * KMP-friendly weak reference. JVM/Android map to `java.lang.ref.WeakReference`;
+ * iOS will map to `kotlin.native.ref.WeakReference` when that target is added.
+ *
+ * The referent may be reclaimed by GC at any time after this reference is
+ * constructed — callers must always null-check the result of [get].
+ */
+expect class WeakReference(
+ referent: T,
+) {
+ fun get(): T?
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt
index 47ff0d18f7..172614a573 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/FeedViewModel.kt
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
@Stable
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt
index 7e8f5ec8ca..d4dc7e0a78 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
@Stable
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt
index 032c53a754..66a790c31a 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt
index a3b0b2c32c..1f3f0bd1ed 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt
index 0badc90af2..afbbfb5379 100644
--- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt
@@ -212,6 +212,71 @@ class FeedDefinitionSerializerTest {
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" "))
}
+ @Test
+ fun serializesToExpectedWireFormat() {
+ // Pins the byte-exact JSON output so any future change to the
+ // serializer (field order, null handling, number formatting) is
+ // caught here. Users have feeds saved on disk in this exact format
+ // and may share JSON between app versions; downstream consumers
+ // depend on the field order being stable.
+ val feed =
+ FeedDefinition(
+ id = "test-1",
+ name = "Bitcoin",
+ emoji = "₿",
+ pinned = true,
+ pinOrder = 0,
+ source =
+ FeedSource.Filter(
+ hashtags = persistentListOf("bitcoin", "btc"),
+ authors = persistentListOf("abc123"),
+ relays = persistentListOf("wss://relay.damus.io"),
+ excludeAuthors = persistentListOf("spammer"),
+ excludeKeywords = persistentListOf("scam"),
+ kinds = persistentListOf(1, 6),
+ ),
+ refreshMode = RefreshMode.LIVE_STREAM,
+ createdAt = 1000L,
+ )
+
+ val expected =
+ """[{"id":"test-1","name":"Bitcoin","emoji":"₿","pinned":true,""" +
+ """"pinOrder":0,"refreshMode":"LIVE_STREAM","createdAt":1000,""" +
+ """"source":{"type":"filter","hashtags":["bitcoin","btc"],""" +
+ """"authors":["abc123"],"relays":["wss://relay.damus.io"],""" +
+ """"excludeAuthors":["spammer"],"excludeKeywords":["scam"],""" +
+ """"kinds":[1,6]}}]"""
+
+ assertEquals(expected, FeedDefinitionSerializer.serializeList(listOf(feed)))
+ }
+
+ @Test
+ fun parsesLegacyJacksonOutput() {
+ // Wire format previously emitted by ObjectMapper. Users have feed
+ // definitions stored on disk in exactly this shape — the new
+ // kotlinx.serialization-backed parser must keep accepting it.
+ val legacy =
+ """[{"id":"x","name":"Bitcoin","emoji":"₿","pinned":true,"pinOrder":0,""" +
+ """"refreshMode":"LIVE_STREAM","createdAt":1000,""" +
+ """"source":{"type":"filter","hashtags":["bitcoin","btc"],"authors":["abc"],""" +
+ """"relays":[],"excludeAuthors":[],"excludeKeywords":[],"kinds":[1,6]}}]"""
+
+ val feeds = FeedDefinitionSerializer.deserializeList(legacy)
+ assertEquals(1, feeds.size)
+ val feed = feeds[0]
+ assertEquals("x", feed.id)
+ assertEquals("Bitcoin", feed.name)
+ assertEquals("₿", feed.emoji)
+ assertTrue(feed.pinned)
+ assertEquals(0, feed.pinOrder)
+ assertEquals(RefreshMode.LIVE_STREAM, feed.refreshMode)
+ assertEquals(1000L, feed.createdAt)
+ val source = feed.source as FeedSource.Filter
+ assertEquals(persistentListOf("bitcoin", "btc"), source.hashtags)
+ assertEquals(persistentListOf("abc"), source.authors)
+ assertEquals(persistentListOf(1, 6), source.kinds)
+ }
+
@Test
fun defaultFeedsAreValid() {
val defaults = defaultFeeds()
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt
new file mode 100644
index 0000000000..316c8b3743
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ChannelRelaysTest.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.model
+
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Lock in the descending-by-usage ordering and equal-count preservation of
+ * [Channel.relays]. Phase 2 KMP migration replaced the JVM `toSortedSet`
+ * (TreeSet) implementation — which silently dropped duplicates whose
+ * comparator returned 0 — with a LinkedHashSet built from a pre-sorted list.
+ * That keeps every distinct relay even when usage counts tie.
+ */
+class ChannelRelaysTest {
+ /** Minimal concrete subclass — the base [Channel] is abstract. */
+ private class TestChannel : Channel() {
+ override fun toBestDisplayName(): String = "test"
+ }
+
+ private val relayA = NormalizedRelayUrl("wss://a.example/")
+ private val relayB = NormalizedRelayUrl("wss://b.example/")
+ private val relayC = NormalizedRelayUrl("wss://c.example/")
+
+ @Test
+ fun emptyWhenNoRelaysAdded() {
+ assertEquals(emptySet(), TestChannel().relays())
+ }
+
+ @Test
+ fun singleRelayIsReturned() {
+ val channel = TestChannel()
+ channel.addRelay(relayA)
+ assertEquals(setOf(relayA), channel.relays())
+ }
+
+ @Test
+ fun relaysAreOrderedDescendingByUsageCount() {
+ val channel = TestChannel()
+ // C will end up with the highest count.
+ channel.addRelay(relayA) // A: 1
+ channel.addRelay(relayB) // B: 1
+ channel.addRelay(relayB) // B: 2
+ channel.addRelay(relayC) // C: 1
+ channel.addRelay(relayC) // C: 2
+ channel.addRelay(relayC) // C: 3
+
+ // LinkedHashSet preserves insertion order, which is the
+ // descending-by-count order produced by sortedByDescending.
+ assertEquals(listOf(relayC, relayB, relayA), channel.relays().toList())
+ }
+
+ /**
+ * Behavioural change from the JVM-only implementation: the previous
+ * `toSortedSet` returned a [java.util.TreeSet] whose membership uses the
+ * comparator for equality. Two relays with equal counts would be treated
+ * as duplicates by the TreeSet and one would be silently dropped. The
+ * KMP-friendly replacement uses [LinkedHashSet], whose membership uses
+ * `equals`, so every distinct relay is preserved regardless of count.
+ */
+ @Test
+ fun relaysWithEqualUsageCountsAreAllPreserved() {
+ val channel = TestChannel()
+ channel.addRelay(relayA) // A: 1
+ channel.addRelay(relayB) // B: 1
+ channel.addRelay(relayC) // C: 1
+
+ // All three relays share count 1. The old behaviour would return
+ // exactly one of them; the new behaviour returns all three.
+ assertEquals(setOf(relayA, relayB, relayC), channel.relays())
+ assertEquals(3, channel.relays().size)
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt
index 59064d5512..5bd761e324 100644
--- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import java.math.BigDecimal
+import com.vitorpamplona.quartz.utils.BigDecimal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -67,7 +67,7 @@ class NoteOnchainZapTest {
assertEquals(1000L, entry?.claimedSats)
assertEquals(0L, entry?.verifiedSats)
assertEquals(OnchainZapStatus.UNVERIFIED, entry?.status)
- assertEquals(BigDecimal.ZERO, target.zapsAmount)
+ assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -82,7 +82,7 @@ class NoteOnchainZapTest {
assertSame(src, entry?.source)
assertEquals(1000L, entry?.verifiedSats)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
- assertEquals(BigDecimal.ZERO, target.zapsAmount)
+ assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -93,7 +93,7 @@ class NoteOnchainZapTest {
target.addOnchainZap(src, "tx1", claimedSats = 5000L, verifiedSats = 5000L, status = OnchainZapStatus.CONFIRMED)
assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status)
- assertEquals(BigDecimal.valueOf(5000L), target.zapsAmount)
+ assertEquals(BigDecimal(5000L), target.zapsAmount)
}
@Test
@@ -109,7 +109,7 @@ class NoteOnchainZapTest {
assertSame(secondSrc, entry?.source)
assertEquals(OnchainZapStatus.PENDING, entry?.status)
assertEquals(2400L, entry?.verifiedSats)
- assertEquals(BigDecimal.ZERO, target.zapsAmount)
+ assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -124,7 +124,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(OnchainZapStatus.CONFIRMED, entry?.status)
- assertEquals(BigDecimal.valueOf(2500L), target.zapsAmount)
+ assertEquals(BigDecimal(2500L), target.zapsAmount)
}
@Test
@@ -139,7 +139,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(OnchainZapStatus.CONFIRMED, entry?.status)
- assertEquals(BigDecimal.valueOf(7500L), target.zapsAmount)
+ assertEquals(BigDecimal(7500L), target.zapsAmount)
}
@Test
@@ -188,7 +188,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(999L, entry?.verifiedSats)
- assertEquals(BigDecimal.valueOf(999L), target.zapsAmount)
+ assertEquals(BigDecimal(999L), target.zapsAmount)
}
@Test
@@ -206,7 +206,7 @@ class NoteOnchainZapTest {
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
- assertEquals(BigDecimal.valueOf(1000L), target.zapsAmount)
+ assertEquals(BigDecimal(1000L), target.zapsAmount)
}
@Test
@@ -224,7 +224,7 @@ class NoteOnchainZapTest {
val secondEntry = target.onchainZaps["tx1"]
assertSame(firstEntry, secondEntry)
- assertEquals(BigDecimal.valueOf(100L), target.zapsAmount)
+ assertEquals(BigDecimal(100L), target.zapsAmount)
}
@Test
@@ -238,13 +238,13 @@ class NoteOnchainZapTest {
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.PENDING)
// PENDING entries don't contribute to total per spec.
- assertEquals(BigDecimal.ZERO, target.zapsAmount)
+ assertEquals(BigDecimal(0), target.zapsAmount)
assertNotNull(target.onchainZaps["tx1"])
target.removeOnchainZapForSource("tx1", srcKey)
assertNull(target.onchainZaps["tx1"])
- assertEquals(BigDecimal.ZERO, target.zapsAmount)
+ assertEquals(BigDecimal(0), target.zapsAmount)
}
@Test
@@ -277,13 +277,13 @@ class NoteOnchainZapTest {
val src = sourceNote(srcKey)
target.addOnchainZap(src, "tx1", claimedSats = 4200L, verifiedSats = 4200L, status = OnchainZapStatus.CONFIRMED)
- assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
+ assertEquals(BigDecimal(4200L), target.zapsAmount)
target.removeOnchainZapForSource("tx1", srcKey)
assertNotNull(target.onchainZaps["tx1"])
assertEquals(OnchainZapStatus.CONFIRMED, target.onchainZaps["tx1"]?.status)
- assertEquals(BigDecimal.valueOf(4200L), target.zapsAmount)
+ assertEquals(BigDecimal(4200L), target.zapsAmount)
}
@Test
@@ -342,7 +342,7 @@ class NoteOnchainZapTest {
target.addOnchainZap(sourceNote("82".repeat(32)), "tx3", claimedSats = 9999L, verifiedSats = 9999L, status = OnchainZapStatus.PENDING)
target.addOnchainZap(sourceNote("93".repeat(32)), "tx4", claimedSats = 1234L, verifiedSats = 0L, status = OnchainZapStatus.UNVERIFIED)
- assertEquals(BigDecimal.valueOf(3000L), target.zapsAmount)
+ assertEquals(BigDecimal(3000L), target.zapsAmount)
assertEquals(4, target.onchainZaps.size)
assertTrue(target.onchainZaps.containsKey("tx3"))
assertTrue(target.onchainZaps.containsKey("tx4"))
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt
new file mode 100644
index 0000000000..ee7ea2c932
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/CodePointsTest.kt
@@ -0,0 +1,152 @@
+/*
+ * 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.util
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CodePointsTest {
+ // ---- codePointCharCount ----
+
+ @Test
+ fun charCountIsOneForBmp() {
+ assertEquals(1, codePointCharCount(0x0041)) // 'A'
+ assertEquals(1, codePointCharCount(0xFFFF)) // last BMP code point
+ }
+
+ @Test
+ fun charCountIsTwoForSupplementary() {
+ assertEquals(2, codePointCharCount(0x10000)) // first supplementary code point
+ assertEquals(2, codePointCharCount(0x1F600)) // 😀 grinning face
+ assertEquals(2, codePointCharCount(0x10FFFF)) // last valid code point
+ }
+
+ // ---- codePointToChars ----
+
+ @Test
+ fun toCharsRoundTripsAscii() {
+ assertArrayEquals(charArrayOf('A'), codePointToChars(0x0041))
+ }
+
+ @Test
+ fun toCharsRoundTripsLastBmp() {
+ assertArrayEquals(charArrayOf(''), codePointToChars(0xFFFF))
+ }
+
+ @Test
+ fun toCharsProducesSurrogatePairForGrinningFace() {
+ // U+1F600 (😀) is encoded as the surrogate pair (0xD83D, 0xDE00).
+ assertArrayEquals(charArrayOf('\uD83D', '\uDE00'), codePointToChars(0x1F600))
+ }
+
+ @Test
+ fun toCharsProducesSurrogatePairForFirstSupplementary() {
+ // U+10000 -> (0xD800, 0xDC00).
+ assertArrayEquals(charArrayOf('\uD800', '\uDC00'), codePointToChars(0x10000))
+ }
+
+ @Test
+ fun toCharsProducesSurrogatePairForLastCodePoint() {
+ // U+10FFFF -> (0xDBFF, 0xDFFF).
+ assertArrayEquals(charArrayOf('\uDBFF', '\uDFFF'), codePointToChars(0x10FFFF))
+ }
+
+ // ---- String.codePointAtKmp ----
+
+ @Test
+ fun codePointAtReturnsAsciiCodeForBmp() {
+ assertEquals(0x0041, "Aa".codePointAtKmp(0))
+ assertEquals(0x0061, "Aa".codePointAtKmp(1))
+ }
+
+ @Test
+ fun codePointAtDecodesSurrogatePair() {
+ // "😀X" — surrogate pair followed by 'X'.
+ val s = "😀X"
+ assertEquals(0x1F600, s.codePointAtKmp(0))
+ }
+
+ @Test
+ fun codePointAtReturnsLowSurrogateWhenIndexIsOnLowHalf() {
+ // Indexing into the middle of a surrogate pair returns the low surrogate's raw code unit.
+ val s = "😀"
+ assertEquals(0xDE00, s.codePointAtKmp(1))
+ }
+
+ @Test
+ fun codePointAtReturnsHighSurrogateWhenStringEndsOnLoneHighSurrogate() {
+ // Lone high surrogate at end of string: `index + 1 < length` is false, so we
+ // fall back to returning the surrogate's raw code unit. This matches
+ // java.lang.Character.codePointAt(CharSequence, int).
+ val s = "\uD83D"
+ assertEquals(0xD83D, s.codePointAtKmp(0))
+ }
+
+ @Test
+ fun codePointAtReturnsHighSurrogateWhenFollowedByNonSurrogate() {
+ // High surrogate followed by a non-low-surrogate char: return the high surrogate raw.
+ val s = "\uD83DA"
+ assertEquals(0xD83D, s.codePointAtKmp(0))
+ assertEquals(0x0041, s.codePointAtKmp(1))
+ }
+
+ // ---- String.offsetByCodePointsKmp ----
+
+ @Test
+ fun offsetByOneStepsOverBmp() {
+ // Two ASCII chars; offset 1 from index 0 lands on index 1.
+ assertEquals(1, "Aa".offsetByCodePointsKmp(0, 1))
+ }
+
+ @Test
+ fun offsetByOneStepsOverSupplementaryPair() {
+ // 😀 is 2 UTF-16 code units; offset 1 from index 0 lands on index 2.
+ val s = "😀X"
+ assertEquals(2, s.offsetByCodePointsKmp(0, 1))
+ // Then a single step lands on the end of the string.
+ assertEquals(3, s.offsetByCodePointsKmp(2, 1))
+ }
+
+ @Test
+ fun offsetByZeroIsIdentity() {
+ assertEquals(0, "Aa".offsetByCodePointsKmp(0, 0))
+ assertEquals(2, "Aa".offsetByCodePointsKmp(2, 0))
+ }
+
+ // ---- Round-trip ----
+
+ @Test
+ fun encodeDecodeRoundTripCoversFullCodePointRange() {
+ // Spot-check a handful of code points across BMP and supplementary planes.
+ val samples = intArrayOf(0x0001, 0x0041, 0x00FF, 0x4F60, 0xFFFF, 0x10000, 0x1F600, 0x10FFFF)
+ for (cp in samples) {
+ val chars = codePointToChars(cp)
+ val asString = chars.concatToString()
+ assertEquals("round-trip code point U+${cp.toString(16).uppercase()}", cp, asString.codePointAtKmp(0))
+ assertEquals(
+ "char count for U+${cp.toString(16).uppercase()}",
+ chars.size,
+ codePointCharCount(cp),
+ )
+ }
+ }
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt
new file mode 100644
index 0000000000..54bfed7b5c
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.ios.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.blurhash
+
+// Phase 2 compile-only iOS actual. Holds an ARGB pixel buffer + size. Phase 3
+// will swap to a UIImage/CGImage-backed implementation when the iosApp module
+// lands and BlurHash/ThumbHash output needs to render to UIKit.
+actual class PlatformImage internal constructor(
+ private val pixels: IntArray,
+ actual val width: Int,
+ actual val height: Int,
+) {
+ actual fun getPixels(
+ pixels: IntArray,
+ offset: Int,
+ stride: Int,
+ x: Int,
+ y: Int,
+ width: Int,
+ height: Int,
+ ) {
+ for (row in 0 until height) {
+ val srcStart = (y + row) * this.width + x
+ val dstStart = offset + row * stride
+ this.pixels.copyInto(
+ destination = pixels,
+ destinationOffset = dstStart,
+ startIndex = srcStart,
+ endIndex = srcStart + width,
+ )
+ }
+ }
+
+ actual fun scale(
+ width: Int,
+ height: Int,
+ ): PlatformImage {
+ // Nearest-neighbour resample. Adequate for the Phase 2 compile-only target;
+ // Phase 3 should swap to CoreGraphics scaling for production quality.
+ val out = IntArray(width * height)
+ for (j in 0 until height) {
+ val srcY = (j.toLong() * this.height / height).toInt().coerceAtMost(this.height - 1)
+ for (i in 0 until width) {
+ val srcX = (i.toLong() * this.width / width).toInt().coerceAtMost(this.width - 1)
+ out[j * width + i] = pixels[srcY * this.width + srcX]
+ }
+ }
+ return PlatformImage(out, width, height)
+ }
+
+ actual companion object {
+ actual fun create(
+ pixels: IntArray,
+ width: Int,
+ height: Int,
+ ): PlatformImage = PlatformImage(pixels.copyOf(), width, height)
+ }
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt
new file mode 100644
index 0000000000..268b92d6a7
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.ios.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.chess
+
+// Phase 2 compile-only iOS actual. In-memory only; persistence via
+// NSUserDefaults arrives with the iosApp module in Phase 3.
+actual class ChessDismissedGamesStorage private actual constructor() {
+ private val dismissed = mutableMapOf>()
+
+ actual companion object {
+ actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage()
+ }
+
+ actual fun load(userPubkey: String): Set = dismissed[userPubkey] ?: emptySet()
+
+ actual fun save(
+ userPubkey: String,
+ ids: Set,
+ ) {
+ if (ids.isEmpty()) {
+ dismissed.remove(userPubkey)
+ } else {
+ dismissed[userPubkey] = ids
+ }
+ }
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt
new file mode 100644
index 0000000000..d67627da12
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.ios.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.keystorage
+
+// Phase 2 compile-only iOS actual. Keychain Services wiring lands in Phase 4
+// (write paths: signing, posting, settings) per amethyst/plans/2026-05-24-ios-support.md.
+actual class SecureKeyStorage private actual constructor() {
+ actual companion object {
+ actual fun create(context: Any?): SecureKeyStorage = SecureKeyStorage()
+ }
+
+ actual suspend fun savePrivateKey(
+ npub: String,
+ privKeyHex: String,
+ ): Unit = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
+
+ actual suspend fun getPrivateKey(npub: String): String? = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
+
+ actual suspend fun deletePrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
+
+ actual suspend fun hasPrivateKey(npub: String): Boolean = throw SecureStorageException("Keychain Services binding pending (iOS Phase 4)")
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt
new file mode 100644
index 0000000000..960eb5282b
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadLevelCalculator.ios.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
+
+package com.vitorpamplona.amethyst.commons.model
+
+import com.vitorpamplona.amethyst.commons.util.KmpLock
+import com.vitorpamplona.amethyst.commons.util.withLock
+import platform.Foundation.NSDate
+import platform.Foundation.NSDateFormatter
+import platform.Foundation.NSLocale
+import platform.Foundation.NSTimeZone
+import platform.Foundation.dateWithTimeIntervalSince1970
+import platform.Foundation.localTimeZone
+
+// Format pattern matches the JVM actual ("uuuu-MM-dd-HH:mm:ss"). For the
+// post-1970 timestamps Nostr events use, `yyyy` and `uuuu` are equivalent
+// (proleptic Gregorian, year >= 1), so NSDateFormatter's `yyyy` is fine.
+private val levelFormatter: NSDateFormatter =
+ NSDateFormatter().apply {
+ dateFormat = "yyyy-MM-dd-HH:mm:ss"
+ timeZone = NSTimeZone.localTimeZone
+ locale = NSLocale("en_US_POSIX")
+ }
+
+// NSDateFormatter is not thread-safe for concurrent stringFromDate calls
+// (Apple docs). ThreadLevelCalculator.replyLevelSignature runs under
+// Dispatchers.IO via LevelFeedViewModel, so multiple threads can hit
+// formattedDateTime simultaneously. Guard the shared formatter rather than
+// allocating one per call (cheaper for the O(notes-per-thread-sort) call rate).
+private val levelFormatterLock = KmpLock()
+
+actual fun formattedDateTime(timestamp: Long): String =
+ levelFormatterLock.withLock {
+ levelFormatter.stringFromDate(NSDate.dateWithTimeIntervalSince1970(timestamp.toDouble()))
+ }
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt
new file mode 100644
index 0000000000..7eff5bb65b
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.ios.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.threading
+
+// Mirrors the jvmMain no-op. The iosApp module (Phase 3) can swap this for an
+// `NSThread.isMainThread` check if iOS-specific main-thread restrictions need
+// enforcement; today there are none.
+actual fun checkNotInMainThread() {
+ // No-op
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt
new file mode 100644
index 0000000000..71cfb217fe
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.ios.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.util
+
+import platform.Foundation.NSRecursiveLock
+
+actual class KmpLock {
+ // NSRecursiveLock mirrors ReentrantLock semantics on JVM/Android — a
+ // thread that already holds the lock can re-enter without deadlock.
+ private val delegate = NSRecursiveLock()
+
+ actual fun lock() = delegate.lock()
+
+ actual fun unlock() = delegate.unlock()
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt
new file mode 100644
index 0000000000..6179b77777
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.ios.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
+
+package com.vitorpamplona.amethyst.commons.util
+
+import platform.Foundation.NSNumber
+import platform.Foundation.NSNumberFormatter
+import platform.Foundation.NSNumberFormatterDecimalStyle
+import platform.Foundation.numberWithLongLong
+
+actual class PlatformNumberFormatter {
+ private val formatter: NSNumberFormatter =
+ NSNumberFormatter().apply {
+ numberStyle = NSNumberFormatterDecimalStyle
+ }
+
+ actual fun format(value: Long): String = formatter.stringFromNumber(NSNumber.numberWithLongLong(value)) ?: value.toString()
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt
new file mode 100644
index 0000000000..c72e2da008
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.ios.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.util
+
+import platform.Foundation.NSURL
+
+// Schemes that JVM's URI.toURL() requires a host for (network schemes).
+// "http:" without a host throws MalformedURLException on JVM; NSURL accepts
+// it. Explicitly reject to keep behavior aligned.
+private val HOST_REQUIRED_SCHEMES = setOf("http", "https", "ws", "wss", "ftp")
+
+actual fun isValidUrl(url: String?): Boolean {
+ if (url == null) return false
+ // NSURL.URLWithString returns null for syntactically invalid URLs. It is
+ // more permissive than JVM's URI(url).toURL() — it accepts scheme-less
+ // relative URLs ("foo", "/bar") and scheme-only inputs ("http:"). Match
+ // the JVM contract (absolute URL with a known scheme and, for network
+ // schemes, a host).
+ val nsUrl = NSURL.URLWithString(url) ?: return false
+ val scheme = nsUrl.scheme?.lowercase() ?: return false
+ if (scheme.isEmpty()) return false
+ if (scheme in HOST_REQUIRED_SCHEMES && nsUrl.host.isNullOrEmpty()) return false
+ return true
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt
new file mode 100644
index 0000000000..cc52116fef
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.ios.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+@file:OptIn(kotlin.experimental.ExperimentalNativeApi::class)
+
+package com.vitorpamplona.amethyst.commons.util
+
+actual class WeakReference actual constructor(
+ referent: T,
+) {
+ private val ref = kotlin.native.ref.WeakReference(referent)
+
+ actual fun get(): T? = ref.get()
+}
diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt
new file mode 100644
index 0000000000..169879b250
--- /dev/null
+++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/utils/DebugUtils.ios.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.utils
+
+// iosApp (Phase 3) can flip this from a build flag (`DEBUG` Swift compile
+// condition surfaced through the framework). Compile-only target ships false.
+actual val isDebug: Boolean = false
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt
similarity index 100%
rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt
rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt
similarity index 100%
rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt
rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt
similarity index 100%
rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt
rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt
similarity index 100%
rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt
rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
similarity index 100%
rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt
new file mode 100644
index 0000000000..30c0de6441
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaLocalContent.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.richtext
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
+import java.io.File
+
+// Locally-cached media content. Lives in jvmAndroid because java.io.File is
+// JVM-only; iOS support will arrive when we either swap to okio.Path or pull
+// these classes back into commonMain behind an expect/actual File abstraction.
+// URL-based media classes (MediaUrlImage, MediaUrlVideo, …) remain in
+// commonMain in MediaContentModels.kt.
+
+@Immutable
+abstract class MediaPreloadedContent(
+ val localFile: File?,
+ description: String? = null,
+ val mimeType: String? = null,
+ val isVerified: Boolean? = null,
+ dim: DimensionTag? = null,
+ blurhash: String? = null,
+ val uri: String,
+ val id: String? = null,
+ thumbhash: String? = null,
+) : BaseMediaContent(description, dim, blurhash, thumbhash) {
+ fun localFileExists() = localFile != null && localFile.exists()
+}
+
+@Immutable
+class MediaLocalImage(
+ localFile: File?,
+ mimeType: String? = null,
+ description: String? = null,
+ dim: DimensionTag? = null,
+ blurhash: String? = null,
+ isVerified: Boolean? = null,
+ uri: String,
+ thumbhash: String? = null,
+) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
+
+@Immutable
+class MediaLocalVideo(
+ localFile: File?,
+ mimeType: String? = null,
+ description: String? = null,
+ dim: DimensionTag? = null,
+ blurhash: String? = null,
+ isVerified: Boolean? = null,
+ uri: String,
+ val artworkUri: String? = null,
+ val authorName: String? = null,
+ thumbhash: String? = null,
+) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt
new file mode 100644
index 0000000000..db64401eb5
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/KmpLock.jvmAndroid.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.util
+
+import java.util.concurrent.locks.ReentrantLock
+
+actual class KmpLock {
+ private val delegate = ReentrantLock()
+
+ actual fun lock() = delegate.lock()
+
+ actual fun unlock() = delegate.unlock()
+}
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt
new file mode 100644
index 0000000000..70905418b4
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/UrlValidation.jvmAndroid.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.util
+
+import java.net.MalformedURLException
+import java.net.URI
+import java.net.URISyntaxException
+
+actual fun isValidUrl(url: String?): Boolean =
+ try {
+ if (url != null) {
+ URI(url).toURL()
+ true
+ } else {
+ false
+ }
+ } catch (_: MalformedURLException) {
+ false
+ } catch (_: URISyntaxException) {
+ false
+ } catch (_: IllegalArgumentException) {
+ false
+ }
diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt
new file mode 100644
index 0000000000..15b46abcaf
--- /dev/null
+++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/WeakReference.jvmAndroid.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.util
+
+actual typealias WeakReference = java.lang.ref.WeakReference
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt
similarity index 100%
rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt
rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ActiveSubscription.kt
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
similarity index 100%
rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt
similarity index 100%
rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt
rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
index 2797a26071..a314a3449a 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
@@ -1043,6 +1043,7 @@ fun App(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
+ localCache = localCache,
replyTo = replyToNote,
)
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt
new file mode 100644
index 0000000000..c478f7f6ad
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/DesktopCachedRichTextParser.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.desktop.service
+
+import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
+import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
+import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
+
+object DesktopCachedRichTextParser {
+ private const val MAX_CACHE_SIZE = 50
+
+ private val cache =
+ java.util.Collections.synchronizedMap(
+ object : LinkedHashMap(64, 0.75f, true) {
+ override fun removeEldestEntry(eldest: Map.Entry) = size > MAX_CACHE_SIZE
+ },
+ )
+
+ fun parseText(
+ content: String,
+ tags: ImmutableListOfLists,
+ callbackUri: String? = null,
+ ): RichTextViewerState {
+ cache[content]?.let { return it }
+ val state = RichTextParser().parseText(content, tags, callbackUri)
+ cache[content] = state
+ return state
+ }
+
+ fun isMarkdown(content: String): Boolean =
+ content.startsWith("> ") ||
+ content.startsWith("# ") ||
+ content.contains("##") ||
+ content.contains("__") ||
+ content.contains("**") ||
+ content.contains("```") ||
+ content.contains("](")
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt
index ab5a6c3ef7..9d8f5d3b65 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt
@@ -21,24 +21,32 @@
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -46,14 +54,19 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
+import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.upload.UploadResult
+import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
@@ -98,6 +111,7 @@ fun ComposeNoteDialog(
onDismiss: () -> Unit,
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
+ localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null,
replyTo: Event? = null,
quoteOf: Event? = null,
) {
@@ -111,8 +125,43 @@ fun ComposeNoteDialog(
""
}
}
- var content by remember { mutableStateOf(initialContent) }
+ var contentField by remember {
+ mutableStateOf(
+ TextFieldValue(
+ text = initialContent,
+ selection = TextRange(initialContent.length),
+ ),
+ )
+ }
+ val content = contentField.text
var isPosting by remember { mutableStateOf(false) }
+
+ // Mention autocomplete state
+ var mentionSuggestions by remember { mutableStateOf>(emptyList()) }
+ var mentionQuery by remember { mutableStateOf(null) }
+ var mentionWordStart by remember { mutableStateOf(0) }
+
+ LaunchedEffect(contentField) {
+ val cursor = contentField.selection.end
+ val text = contentField.text
+ if (cursor > 0 && localCache != null) {
+ // Find the word at cursor
+ val wordStart = text.lastIndexOf(' ', cursor - 1) + 1
+ val wordAtCursor = text.substring(wordStart, cursor)
+ if (wordAtCursor.startsWith("@") && wordAtCursor.length > 1) {
+ val query = wordAtCursor.removePrefix("@")
+ mentionQuery = query
+ mentionWordStart = wordStart
+ mentionSuggestions = localCache.findUsersStartingWith(query, 5)
+ } else {
+ mentionQuery = null
+ mentionSuggestions = emptyList()
+ }
+ } else {
+ mentionQuery = null
+ mentionSuggestions = emptyList()
+ }
+ }
var errorMessage by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
val attachedFiles = remember { mutableStateListOf() }
@@ -208,22 +257,55 @@ fun ComposeNoteDialog(
Spacer(Modifier.height(16.dp))
- OutlinedTextField(
- value = if (postAsPicture) "" else content,
- onValueChange = {
- content = it
- errorMessage = null
- },
- modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
- label = {
- Text(
- if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
- )
- },
- placeholder = { Text(if (postAsPicture) "" else "Write your note...") },
- enabled = !isPosting && !postAsPicture,
- maxLines = if (postAsPicture) 1 else 10,
- )
+ Box {
+ OutlinedTextField(
+ value = if (postAsPicture) TextFieldValue("") else contentField,
+ onValueChange = {
+ contentField = it
+ errorMessage = null
+ },
+ modifier = Modifier.fillMaxWidth().height(if (postAsPicture) 60.dp else 200.dp),
+ label = {
+ Text(
+ if (postAsPicture) "Text disabled for picture posts" else "What's on your mind?",
+ )
+ },
+ placeholder = { Text(if (postAsPicture) "" else "Write your note... (type @ to mention)") },
+ enabled = !isPosting && !postAsPicture,
+ maxLines = if (postAsPicture) 1 else 10,
+ )
+
+ // Mention autocomplete dropdown
+ if (mentionSuggestions.isNotEmpty()) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ ) {
+ LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) {
+ items(mentionSuggestions, key = { it.pubkeyHex }) { user ->
+ MentionSuggestionRow(
+ user = user,
+ onClick = {
+ val npub = user.pubkeyNpub()
+ val replacement = "nostr:$npub "
+ val cursorEnd = contentField.selection.end
+ val newText =
+ contentField.text.replaceRange(
+ mentionWordStart,
+ cursorEnd,
+ replacement,
+ )
+ val newCursor = mentionWordStart + replacement.length
+ contentField = TextFieldValue(newText, TextRange(newCursor))
+ mentionSuggestions = emptyList()
+ mentionQuery = null
+ },
+ )
+ }
+ }
+ }
+ }
+ }
Spacer(Modifier.height(8.dp))
@@ -590,3 +672,39 @@ private suspend fun publishNote(
relayManager.publish(signedEvent, relays)
}
}
+
+@Composable
+private fun MentionSuggestionRow(
+ user: User,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ UserAvatar(
+ userHex = user.pubkeyHex,
+ pictureUrl = user.profilePicture(),
+ size = 28.dp,
+ contentDescription = null,
+ )
+ Column {
+ Text(
+ text = user.toBestDisplayName(),
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ )
+ Text(
+ text = user.pubkeyNpub().take(24) + "...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt
index 26650f5140..0367228efa 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
+import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -49,5 +50,6 @@ fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData {
profilePictureUrl = pictureUrl,
content = content,
createdAt = createdAt,
+ tags = ImmutableListOfLists(tags),
)
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
index 8c666e145c..4bc55e7cee 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
@@ -126,6 +126,7 @@ fun FeedNoteCard(
onNavigateToThread: (String) -> Unit = {},
onImageClick: ((List, Int) -> Unit)? = null,
onMediaClick: ((List, Int, Float) -> Unit)? = null,
+ onHashtagClick: ((String) -> Unit)? = null,
) {
val event = note.event ?: return
val isRepost = event is RepostEvent || event is GenericRepostEvent
@@ -195,6 +196,7 @@ fun FeedNoteCard(
onClick = { onNavigateToThread(originalEvent.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
+ onHashtagClick = onHashtagClick,
onImageClick = onImageClick,
onMediaClick = onMediaClick,
)
@@ -247,6 +249,7 @@ fun FeedNoteCard(
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
+ onHashtagClick = onHashtagClick,
onImageClick = onImageClick,
onMediaClick = onMediaClick,
)
@@ -686,6 +689,7 @@ fun FeedScreen(
onDismiss = { replyToEvent = null },
relayManager = relayManager,
account = account,
+ localCache = localCache,
replyTo = replyToEvent,
)
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
index 8f90bfdf00..a515977d43 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
@@ -1354,6 +1354,13 @@ fun NoteActionsRow(
showOverflowMenu = false
},
)
+ DropdownMenuItem(
+ text = { Text("Copy Raw JSON") },
+ onClick = {
+ copyToClipboard(event.toJson())
+ showOverflowMenu = false
+ },
+ )
}
}
}
@@ -1400,6 +1407,7 @@ fun NoteActionsRow(
onDismiss = { quoteEvent = null },
relayManager = relayManager,
account = account,
+ localCache = localCache,
quoteOf = quoteEvent,
)
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt
index 5f8661a258..27060880ba 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt
@@ -24,6 +24,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -43,6 +44,8 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -63,7 +66,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.isSecondaryPressed
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -93,6 +99,10 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
+import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
+import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
+import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers
@@ -138,6 +148,30 @@ fun UserProfileScreen(
)
}
var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) }
+ var nip05 by remember {
+ mutableStateOf(
+ cachedMetadata
+ ?.flow
+ ?.value
+ ?.info
+ ?.nip05,
+ )
+ }
+ var website by remember {
+ mutableStateOf(
+ cachedMetadata
+ ?.flow
+ ?.value
+ ?.info
+ ?.website,
+ )
+ }
+ var lnAddress by remember { mutableStateOf(cachedMetadata?.lnAddress()) }
+ var identities by remember {
+ mutableStateOf(
+ cachedMetadata?.flow?.value?.identities ?: emptyList(),
+ )
+ }
var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) }
var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) }
@@ -258,6 +292,10 @@ fun UserProfileScreen(
displayName = metadata.displayName ?: metadata.name
about = metadata.about
picture = metadata.picture
+ nip05 = metadata.nip05
+ website = metadata.website
+ lnAddress = metadata.lnAddress()
+ identities = event.identityClaims()
}
// Store MetadataEvent for editing (only for own profile)
@@ -678,6 +716,68 @@ fun UserProfileScreen(
)
}
+ // Profile metadata fields
+ nip05?.takeIf { it.isNotBlank() }?.let { addr ->
+ Spacer(Modifier.height(8.dp))
+ ProfileMetadataField(
+ text = addr,
+ icon = MaterialSymbols.CheckCircle,
+ onClick = {
+ runCatching {
+ java.awt.Desktop.getDesktop().browse(
+ java.net.URI("https://${addr.substringAfter("@")}"),
+ )
+ }
+ },
+ )
+ }
+
+ website?.takeIf { it.isNotBlank() }?.let { site ->
+ Spacer(Modifier.height(4.dp))
+ ProfileMetadataField(
+ text = site.removePrefix("https://").removePrefix("http://").removeSuffix("/"),
+ copyValue = site,
+ icon = MaterialSymbols.Language,
+ onClick = {
+ runCatching {
+ val url = if (site.contains("://")) site else "https://$site"
+ java.awt.Desktop
+ .getDesktop()
+ .browse(java.net.URI(url))
+ }
+ },
+ )
+ }
+
+ lnAddress?.takeIf { it.isNotBlank() }?.let { addr ->
+ Spacer(Modifier.height(4.dp))
+ ProfileMetadataField(
+ text = addr,
+ icon = MaterialSymbols.Bolt,
+ )
+ }
+
+ identities.forEach { identity ->
+ Spacer(Modifier.height(4.dp))
+ ProfileMetadataField(
+ text =
+ when (identity) {
+ is TwitterIdentity -> "@${identity.identity}"
+ is GitHubIdentity -> identity.identity
+ is MastodonIdentity -> identity.identity
+ else -> identity.identity
+ },
+ icon = MaterialSymbols.Language,
+ onClick = {
+ runCatching {
+ java.awt.Desktop.getDesktop().browse(
+ java.net.URI(identity.toProofUrl()),
+ )
+ }
+ },
+ )
+ }
+
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -1173,3 +1273,64 @@ private fun PublishedHighlightCard(
}
}
}
+
+@Composable
+private fun ProfileMetadataField(
+ text: String,
+ copyValue: String = text,
+ icon: com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol? = null,
+ onClick: () -> Unit = {},
+) {
+ var showContextMenu by remember { mutableStateOf(false) }
+
+ Box(
+ modifier =
+ Modifier
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ val event = awaitPointerEvent()
+ if (
+ event.buttons.isSecondaryPressed &&
+ event.changes.any { it.pressed && !it.previousPressed }
+ ) {
+ showContextMenu = true
+ }
+ }
+ }
+ }.clickable { onClick() },
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 2.dp),
+ ) {
+ if (icon != null) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.width(4.dp))
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ DropdownMenu(expanded = showContextMenu, onDismissRequest = { showContextMenu = false }) {
+ DropdownMenuItem(
+ text = { Text("Copy") },
+ onClick = {
+ val clipboard = Toolkit.getDefaultToolkit().systemClipboard
+ clipboard.setContents(StringSelection(copyValue), null)
+ showContextMenu = false
+ },
+ )
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt
new file mode 100644
index 0000000000..9039978f28
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt
@@ -0,0 +1,700 @@
+/*
+ * 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.desktop.ui.note
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
+import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
+import com.vitorpamplona.amethyst.commons.richtext.BechSegment
+import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
+import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
+import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
+import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
+import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
+import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment
+import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
+import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
+import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
+import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
+import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
+import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment
+import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
+import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
+import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
+import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
+import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
+import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
+import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
+import com.vitorpamplona.amethyst.commons.richtext.Segment
+import com.vitorpamplona.amethyst.commons.richtext.VideoSegment
+import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
+import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
+import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
+import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
+import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
+import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
+import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
+import kotlinx.collections.immutable.ImmutableMap
+import java.awt.Toolkit
+import java.awt.datatransfer.StringSelection
+import java.net.URI
+
+data class RichTextCallbacks(
+ val onMentionClick: ((String) -> Unit)? = null,
+ val onHashtagClick: ((String) -> Unit)? = null,
+ val onNavigateToThread: ((String) -> Unit)? = null,
+ val onImageClick: ((List, Int) -> Unit)? = null,
+ val onPayInvoice: ((String) -> Unit)? = null,
+)
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun DesktopRichTextViewer(
+ content: String,
+ state: RichTextViewerState,
+ localCache: DesktopLocalCache? = null,
+ callbacks: RichTextCallbacks = RichTextCallbacks(),
+ modifier: Modifier = Modifier,
+) {
+ if (DesktopCachedRichTextParser.isMarkdown(content)) {
+ RenderMarkdown(
+ content = content,
+ onLinkClick = { url ->
+ when {
+ url.startsWith("nostr:") -> {
+ val parsed = Nip19Parser.uriToRoute(url)
+ when (val entity = parsed?.entity) {
+ is NPub -> {
+ callbacks.onMentionClick?.invoke(entity.hex)
+ }
+
+ is NProfile -> {
+ callbacks.onMentionClick?.invoke(entity.hex)
+ }
+
+ is NNote -> {
+ callbacks.onNavigateToThread?.invoke(entity.hex)
+ }
+
+ is NEvent -> {
+ callbacks.onNavigateToThread?.invoke(entity.hex)
+ }
+
+ else -> {}
+ }
+ }
+
+ else -> {
+ runCatching {
+ java.awt.Desktop
+ .getDesktop()
+ .browse(URI(url))
+ }
+ }
+ }
+ },
+ modifier = modifier,
+ )
+ return
+ }
+
+ Column(modifier = modifier) {
+ for (paragraph in state.paragraphs) {
+ when (paragraph) {
+ is ImageGalleryParagraph -> {
+ val urls = paragraph.words.map { it.segmentText }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ for ((index, segment) in paragraph.words.withIndex()) {
+ AsyncImage(
+ model = segment.segmentText,
+ contentDescription = null,
+ modifier =
+ Modifier
+ .weight(1f)
+ .heightIn(max = 300.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .then(
+ if (callbacks.onImageClick != null) {
+ Modifier.clickable { callbacks.onImageClick.invoke(urls, index) }
+ } else {
+ Modifier
+ },
+ ),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ }
+ }
+
+ else -> {
+ val hasOnlyText = paragraph.words.all { it is RegularTextSegment }
+ if (hasOnlyText) {
+ Text(
+ text = paragraph.words.joinToString("") { it.segmentText },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ } else {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (paragraph.isRTL) Arrangement.End else Arrangement.Start,
+ ) {
+ for (word in paragraph.words) {
+ RenderSegment(word, state, localCache, callbacks)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RenderSegment(
+ segment: Segment,
+ state: RichTextViewerState,
+ localCache: DesktopLocalCache?,
+ callbacks: RichTextCallbacks,
+) {
+ when (segment) {
+ is RegularTextSegment -> {
+ Text(
+ text = segment.segmentText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ is LinkSegment -> {
+ ClickableLink(segment.segmentText, segment.segmentText)
+ }
+
+ is SchemelessUrlSegment -> {
+ ClickableLink("https://${segment.segmentText}", segment.segmentText)
+ }
+
+ is BechSegment -> {
+ RenderBechSegment(segment, localCache, callbacks)
+ }
+
+ is HashTagSegment -> {
+ val display = "#${segment.hashtag}" + (segment.extras ?: "")
+ Text(
+ text = display,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable { callbacks.onHashtagClick?.invoke(segment.hashtag) },
+ )
+ }
+
+ is HashIndexUserSegment -> {
+ val user = localCache?.getUserIfExists(segment.hex)
+ val display = "@${user?.toBestDisplayName() ?: segment.hex.take(8) + "..."}"
+ Text(
+ text = display + (segment.extras ?: ""),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable { callbacks.onMentionClick?.invoke(segment.hex) },
+ )
+ }
+
+ is HashIndexEventSegment -> {
+ QuotedNoteEmbed(
+ noteId = segment.hex,
+ localCache = localCache,
+ onMentionClick = callbacks.onMentionClick,
+ onNavigateToThread = callbacks.onNavigateToThread,
+ )
+ }
+
+ is EmailSegment -> {
+ Text(
+ text = segment.segmentText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable {
+ runCatching {
+ java.awt.Desktop
+ .getDesktop()
+ .browse(URI("mailto:${segment.segmentText}"))
+ }
+ },
+ )
+ }
+
+ is PhoneSegment -> {
+ Text(
+ text = segment.segmentText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ is RelayUrlSegment -> {
+ Text(
+ text = segment.segmentText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable { copyToClipboard(segment.segmentText) },
+ )
+ }
+
+ is EmojiSegment -> {
+ RenderCustomEmojiSegment(segment.segmentText, state.customEmoji)
+ }
+
+ is NowhereLinkSegment -> {
+ RenderNowhereLinkCard(segment)
+ }
+
+ is InvoiceSegment -> {
+ RenderInvoiceCard(segment.segmentText, callbacks)
+ }
+
+ is CashuSegment -> {
+ RenderCashuCard(segment.segmentText)
+ }
+
+ is WithdrawSegment -> {
+ ClickableLink(segment.segmentText, segment.segmentText)
+ }
+
+ is BlossomUriSegment -> {
+ ClickableLink(segment.segmentText, segment.segmentText)
+ }
+
+ is PdfSegment -> {
+ RenderPdfCard(segment.segmentText)
+ }
+
+ is Base64Segment -> {
+ AsyncImage(
+ model = segment.segmentText,
+ contentDescription = null,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(max = 300.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Fit,
+ )
+ }
+
+ is ImageSegment -> {
+ AsyncImage(
+ model = segment.segmentText,
+ contentDescription = null,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(max = 300.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Fit,
+ )
+ }
+
+ is VideoSegment -> {
+ Text(
+ text = segment.segmentText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable {
+ runCatching {
+ java.awt.Desktop
+ .getDesktop()
+ .browse(URI(segment.segmentText))
+ }
+ },
+ )
+ }
+
+ is SecretEmoji -> {
+ RenderSecretEmoji(segment.segmentText)
+ }
+
+ else -> {
+ Text(
+ text = segment.segmentText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ClickableLink(
+ url: String,
+ displayText: String,
+) {
+ Text(
+ text = displayText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable {
+ runCatching {
+ java.awt.Desktop
+ .getDesktop()
+ .browse(URI(url))
+ }
+ },
+ )
+}
+
+@Composable
+private fun RenderBechSegment(
+ segment: BechSegment,
+ localCache: DesktopLocalCache?,
+ callbacks: RichTextCallbacks,
+) {
+ val resolved =
+ remember(segment.segmentText, localCache) {
+ resolveBech32(segment.segmentText, localCache)
+ }
+ when {
+ resolved.noteIdHex != null -> {
+ QuotedNoteEmbed(
+ noteId = resolved.noteIdHex,
+ localCache = localCache,
+ onMentionClick = callbacks.onMentionClick,
+ onNavigateToThread = callbacks.onNavigateToThread,
+ )
+ }
+
+ resolved.pubKeyHex != null -> {
+ Text(
+ text = resolved.displayText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable { callbacks.onMentionClick?.invoke(resolved.pubKeyHex) },
+ )
+ }
+
+ else -> {
+ Text(
+ text = resolved.displayText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RenderInvoiceCard(
+ invoice: String,
+ callbacks: RichTextCallbacks,
+) {
+ val amount =
+ remember(invoice) {
+ runCatching { LnInvoiceUtil.getAmountInSats(invoice) }.getOrNull()
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ symbol = MaterialSymbols.Bolt,
+ contentDescription = "Lightning Invoice",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = if (amount != null) "$amount sats" else "Lightning Invoice",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(onClick = { copyToClipboard(invoice) }) {
+ Icon(
+ symbol = MaterialSymbols.ContentCopy,
+ contentDescription = "Copy",
+ modifier = Modifier.size(16.dp),
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Copy")
+ }
+ if (callbacks.onPayInvoice != null) {
+ TextButton(onClick = { callbacks.onPayInvoice.invoke(invoice) }) {
+ Icon(
+ symbol = MaterialSymbols.Bolt,
+ contentDescription = "Pay",
+ modifier = Modifier.size(16.dp),
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Pay")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RenderCashuCard(token: String) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Text(
+ text = "Cashu Token",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = token.take(40) + "...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(8.dp))
+ TextButton(onClick = { copyToClipboard(token) }) {
+ Icon(
+ symbol = MaterialSymbols.ContentCopy,
+ contentDescription = "Copy",
+ modifier = Modifier.size(16.dp),
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Copy")
+ }
+ }
+ }
+}
+
+@Composable
+private fun RenderPdfCard(url: String) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable {
+ runCatching {
+ java.awt.Desktop
+ .getDesktop()
+ .browse(URI(url))
+ }
+ },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ symbol = MaterialSymbols.PictureAsPdf,
+ contentDescription = "PDF",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = url,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RenderSecretEmoji(text: String) {
+ var expanded by remember { mutableStateOf(false) }
+ if (expanded) {
+ val decoded = remember(text) { runCatching { EmojiCoder.decode(text) }.getOrDefault(text) }
+ Text(
+ text = decoded,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.clickable { expanded = false },
+ )
+ } else {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier =
+ Modifier
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable { expanded = true },
+ )
+ }
+}
+
+@Composable
+private fun RenderCustomEmojiSegment(
+ word: String,
+ customEmoji: ImmutableMap,
+) {
+ val matchedEmoji = remember(word, customEmoji) { customEmoji.entries.firstOrNull { word.contains(it.key) } }
+ if (matchedEmoji != null) {
+ val parts = word.split(matchedEmoji.key, limit = 2)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (parts[0].isNotEmpty()) {
+ Text(parts[0], style = MaterialTheme.typography.bodyMedium)
+ }
+ AsyncImage(
+ model = matchedEmoji.value,
+ contentDescription = matchedEmoji.key,
+ modifier = Modifier.size(20.dp),
+ contentScale = ContentScale.Fit,
+ )
+ if (parts.size > 1 && parts[1].isNotEmpty()) {
+ Text(parts[1], style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ } else {
+ Text(word, style = MaterialTheme.typography.bodyMedium)
+ }
+}
+
+private val nowhereToolLabels =
+ mapOf(
+ "e" to "Nowhere Event",
+ "f" to "Nowhere Fundraiser",
+ "s" to "Nowhere Store",
+ "p" to "Nowhere Petition",
+ "m" to "Nowhere Message",
+ "d" to "Nowhere Drop",
+ "a" to "Nowhere Art",
+ "fo" to "Nowhere Forum",
+ )
+
+@Composable
+private fun RenderNowhereLinkCard(segment: NowhereLinkSegment) {
+ val label = nowhereToolLabels[segment.tool] ?: "Nowhere Site"
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .pointerHoverIcon(PointerIcon.Hand)
+ .clickable {
+ runCatching {
+ java.awt.Desktop
+ .getDesktop()
+ .browse(URI(segment.segmentText))
+ }
+ },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = segment.host,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+private fun copyToClipboard(text: String) {
+ val clipboard = Toolkit.getDefaultToolkit().systemClipboard
+ clipboard.setContents(StringSelection(text), null)
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
index 1b0208f63a..7b152971ec 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt
@@ -46,20 +46,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.text.LinkAnnotation
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.TextLinkStyles
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.style.TextDecoration
-import androidx.compose.ui.text.withLink
-import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
+import com.vitorpamplona.amethyst.commons.model.EmptyTagList
+import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
-import com.vitorpamplona.amethyst.commons.richtext.Urls
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
+import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
@@ -85,6 +80,7 @@ data class NoteDisplayData(
val profilePictureUrl: String? = null,
val content: String,
val createdAt: Long,
+ val tags: ImmutableListOfLists = EmptyTagList,
)
/**
@@ -99,8 +95,10 @@ fun NoteCard(
onClick: (() -> Unit)? = null,
onAuthorClick: ((String) -> Unit)? = null,
onMentionClick: ((String) -> Unit)? = null,
+ onHashtagClick: ((String) -> Unit)? = null,
onImageClick: ((List, Int) -> Unit)? = null,
onMediaClick: ((List, Int, Float) -> Unit)? = null,
+ onPayInvoice: ((String) -> Unit)? = null,
) {
val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) }
val imageUrls =
@@ -135,18 +133,6 @@ fun NoteCard(
}
text
}
- val strippedUrls =
- remember(urls, mediaUrls) {
- Urls(
- withScheme = urls.withScheme - mediaUrls,
- withoutScheme = urls.withoutScheme,
- emails = urls.emails,
- bech32s = urls.bech32s,
- relayUrls = urls.relayUrls,
- blossomUris = urls.blossomUris,
- )
- }
-
// Cap media height to half the window so text is never pushed off-screen
val windowState = LocalWindowState.current
val maxMediaHeight =
@@ -212,11 +198,22 @@ fun NoteCard(
Spacer(Modifier.height(8.dp))
if (strippedContent.isNotBlank()) {
- RichTextContent(
+ val richState =
+ remember(strippedContent, note.tags) {
+ DesktopCachedRichTextParser.parseText(strippedContent, note.tags)
+ }
+ DesktopRichTextViewer(
content = strippedContent,
- urls = strippedUrls,
+ state = richState,
localCache = localCache,
- onMentionClick = onMentionClick,
+ callbacks =
+ RichTextCallbacks(
+ onMentionClick = onMentionClick,
+ onHashtagClick = onHashtagClick,
+ onNavigateToThread = null,
+ onImageClick = onImageClick,
+ onPayInvoice = onPayInvoice,
+ ),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -325,7 +322,7 @@ fun NoteCard(
/**
* Resolved bech32 mention with display text and optional pubkey for click navigation.
*/
-private data class ResolvedMention(
+internal data class ResolvedMention(
val displayText: String,
val pubKeyHex: String? = null,
val noteIdHex: String? = null,
@@ -336,7 +333,7 @@ private data class ResolvedMention(
* For npub/nprofile → @displayName + pubkey hex for navigation.
* For note/nevent → truncated note ID.
*/
-private fun resolveBech32(
+internal fun resolveBech32(
bech32: String,
localCache: DesktopLocalCache?,
): ResolvedMention {
@@ -386,99 +383,6 @@ fun extractMentionedPubkeys(bech32s: Set): List =
}
}
-/**
- * Renders text content with highlighted URLs and clickable nostr: bech32 mentions.
- * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
- * and navigate to profile on click.
- */
-private data class ContentSegment(
- val start: Int,
- val raw: String,
- val isUrl: Boolean,
-)
-
-private fun buildSegments(
- content: String,
- schemeUrls: Collection,
- bech32s: Collection,
-): List {
- val segments = mutableListOf()
- for (url in schemeUrls) {
- val idx = content.indexOf(url)
- if (idx != -1) segments.add(ContentSegment(idx, url, true))
- }
- for (bech32 in bech32s) {
- val idx = content.indexOf(bech32)
- if (idx != -1) segments.add(ContentSegment(idx, bech32, false))
- }
- segments.sortBy { it.start }
- return segments
-}
-
-@Composable
-fun RichTextContent(
- content: String,
- urls: Urls,
- localCache: DesktopLocalCache? = null,
- onMentionClick: ((String) -> Unit)? = null,
- onNavigateToThread: ((String) -> Unit)? = null,
- modifier: Modifier = Modifier,
-) {
- val defaultColor = MaterialTheme.colorScheme.onSurface
- val primaryColor = MaterialTheme.colorScheme.primary
-
- if (urls.withScheme.isEmpty() && urls.bech32s.isEmpty()) {
- Text(
- text = content,
- style = MaterialTheme.typography.bodyMedium,
- color = defaultColor,
- modifier = modifier,
- )
- return
- }
-
- // Resolve bech32s to find quoted notes vs inline mentions
- val resolvedBech32s =
- remember(urls.bech32s, localCache) {
- urls.bech32s.associateWith { resolveBech32(it, localCache) }
- }
-
- // Collect quoted note IDs (nevent/note references)
- val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys }
- val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() }
-
- if (quotedNoteIds.isEmpty()) {
- // No quoted notes — render everything as annotated text
- val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) }
- RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier)
- } else {
- // Has quoted notes — render text + embedded note cards
- Column(modifier = modifier) {
- // Strip quoted note bech32 references from text
- val strippedText =
- remember(content, quotedBech32s) {
- var text = content
- for (bech32 in quotedBech32s) {
- text = text.replace(bech32, "").trim()
- }
- text
- }
-
- if (strippedText.isNotBlank()) {
- val inlineBech32s = urls.bech32s - quotedBech32s
- val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) }
- RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick)
- }
-
- // Render quoted notes as embedded cards
- for (noteId in quotedNoteIds) {
- Spacer(Modifier.height(8.dp))
- QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread)
- }
- }
- }
-}
-
/**
* Renders a quoted note by ID. Observes the note's metadata flow so it
* recomposes when the event arrives asynchronously from a relay fetch.
@@ -533,60 +437,3 @@ fun QuotedNoteEmbed(
}
}
}
-
-@Composable
-private fun RichAnnotatedText(
- content: String,
- segments: List,
- resolvedBech32s: Map,
- defaultColor: androidx.compose.ui.graphics.Color,
- primaryColor: androidx.compose.ui.graphics.Color,
- onMentionClick: ((String) -> Unit)?,
- modifier: Modifier = Modifier,
-) {
- val annotatedText =
- buildAnnotatedString {
- var lastIndex = 0
- for (seg in segments) {
- if (seg.start < lastIndex) continue
- if (seg.start > lastIndex) {
- append(content.substring(lastIndex, seg.start))
- }
- if (seg.isUrl) {
- withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) {
- append(seg.raw)
- }
- } else {
- val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw)
- if (resolved.pubKeyHex != null && onMentionClick != null) {
- val pubKey = resolved.pubKeyHex
- withLink(
- LinkAnnotation.Clickable(
- tag = "mention",
- styles = TextLinkStyles(SpanStyle(color = primaryColor)),
- ) {
- onMentionClick(pubKey)
- },
- ) {
- append(resolved.displayText)
- }
- } else {
- withStyle(SpanStyle(color = primaryColor)) {
- append(resolved.displayText)
- }
- }
- }
- lastIndex = seg.start + seg.raw.length
- }
- if (lastIndex < content.length) {
- append(content.substring(lastIndex))
- }
- }
-
- Text(
- text = annotatedText,
- style = MaterialTheme.typography.bodyMedium,
- color = defaultColor,
- modifier = modifier,
- )
-}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7465579767..96e081ecaf 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -118,6 +118,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a
androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material3-windowSize = { group = "androidx.compose.material3", name = "material3-window-size-class" }
diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts
index a654fcbebb..9652b8df67 100644
--- a/quartz/build.gradle.kts
+++ b/quartz/build.gradle.kts
@@ -358,6 +358,74 @@ dependencies {
"jvmAndroidTestImplementation"(testFixtures(project(":geode")))
}
+// iOS purity gate. Jackson and OkHttp are JVM-only and must never appear
+// in source sets that compile for iOS / native / common targets. The KMP
+// build would eventually fail with an "unresolved reference" if these
+// leaked in, but that requires a macOS runner. This task is a fast Linux
+// linter that fails with a clear message before iOS CI ever spins up.
+//
+// Scope: source sets whose code is compiled for at least one non-JVM
+// target. Excludes jvmAndroid, jvmMain, androidMain (and their tests),
+// where Jackson and OkHttp are legitimately used.
+val verifyKmpPurity by tasks.registering {
+ group = "verification"
+ description = "Fails if iOS-targeted source sets import JVM-only deps."
+ val checkedDirs =
+ listOf(
+ "src/commonMain", "src/commonTest",
+ "src/appleMain", "src/appleTest",
+ "src/nativeMain", "src/nativeTest",
+ "src/iosMain", "src/iosTest",
+ "src/iosArm64Main", "src/iosArm64Test",
+ "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
+ "src/linuxMain", "src/linuxTest",
+ "src/linuxX64Main", "src/linuxX64Test",
+ "src/macosMain", "src/macosTest",
+ "src/macosArm64Main", "src/macosArm64Test",
+ ).map { layout.projectDirectory.dir(it).asFile }
+ .filter { it.exists() }
+ inputs.files(checkedDirs)
+ doLast {
+ // Each pattern is paired with a short hint so the failure message
+ // points at the canonical KMP replacement.
+ val forbidden =
+ listOf(
+ "com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization",
+ "okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS",
+ "System.currentTimeMillis" to "use TimeUtils.now()",
+ "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual",
+ "java.util.UUID" to "use kotlin.uuid.Uuid",
+ "kotlin.jvm.Synchronized" to "use a KMP lock primitive",
+ "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile",
+ )
+ val offenders =
+ checkedDirs.flatMap { dir ->
+ dir.walkTopDown()
+ .filter { it.isFile && it.extension == "kt" }
+ .flatMap { file ->
+ file.readLines().withIndex().mapNotNull { (idx, line) ->
+ val trimmed = line.trimStart()
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
+ return@mapNotNull null
+ }
+ forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) ->
+ "${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint"
+ }
+ }
+ }
+ }
+ if (offenders.isNotEmpty()) {
+ throw GradleException(
+ "iOS-targeted source sets must not reference JVM-only APIs. " +
+ "Move the offending code to jvmAndroid/ or behind an expect/actual:\n " +
+ offenders.joinToString("\n "),
+ )
+ }
+ }
+}
+
+tasks.named("check").configure { dependsOn(verifyKmpPurity) }
+
mavenPublishing {
// sources publishing is always enabled by the Kotlin Multiplatform plugin
configure(
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt
new file mode 100644
index 0000000000..571f812db1
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/BigDecimalOps.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.quartz.utils
+
+operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other)
+
+operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other)