mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/brave-clarke-hJ0PK
This commit is contained in:
@@ -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 '<string name=' amethyst/src/main/res/values/strings.xml \
|
||||
| grep -v 'translatable="false"' \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
<(grep '<string name=' amethyst/src/main/res/values-$locale/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
| while IFS= read -r key; do
|
||||
added_ts=$(git log -1 --format=%ct -S "name=\"$key\"" -- amethyst/src/main/res/values/strings.xml)
|
||||
if [ -n "$added_ts" ] && [ "$added_ts" -gt "$sync_ts" ]; then
|
||||
echo "$key"
|
||||
fi
|
||||
done
|
||||
done
|
||||
```
|
||||
|
||||
**Why this beats using the `l10n_crowdin_translations` branch tip:**
|
||||
- The branch is often deleted after merge — the branch tip then doesn't exist or points to a stale ref.
|
||||
- The branch tip may include Crowdin commits that haven't been merged to main yet. Those changes aren't in our working tree, so they don't affect what's on disk for us. The "reachable from HEAD" cutoff matches what we can actually observe in `values-*/strings.xml`.
|
||||
|
||||
**Only the listed (post-sync) keys are actionable.** Anything older is either:
|
||||
- A deliberate "use English" choice in Crowdin (brand terms like `Nowhere X`, loanwords like `Apps` / `Feed` / `Issues`, version prefixes like `v%1$s`), or
|
||||
- A pending translation the translator hasn't filled in yet — still Crowdin's job, not ours.
|
||||
|
||||
In both cases, Android's resource resolution falls back to `values/strings.xml` at runtime, so there is no user-visible bug. Adding source-identical fallbacks locally is noise that the next sync will strip again.
|
||||
|
||||
Report the pre-sync skipped count as a one-liner ("N keys predate the last Crowdin sync, skipped — Crowdin owns them"). Do not list them or propose translations.
|
||||
|
||||
If no Crowdin export commit can be found in history (`sync_ts=0` fallback), warn the user and fall back to treating all missing keys as actionable — but flag that the workflow is degraded.
|
||||
|
||||
### 3. Get English values for missing keys
|
||||
|
||||
For each missing key, extract its English value:
|
||||
@@ -198,7 +263,10 @@ When adding translated strings to locale files:
|
||||
|
||||
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||||
- **Not checking string-arrays/plurals** — only checking `<string>` 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 `<plurals>` `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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
+86
-26
@@ -1,54 +1,114 @@
|
||||
# Amethyst Privacy Policy and Terms of Use
|
||||
|
||||
## Privacy Policy
|
||||
**App:** Amethyst (Android Nostr client)<br>
|
||||
**Publisher:** Vitor Pamplona<br>
|
||||
**Contact:** amethyst@vitorpamplona.com<br>
|
||||
**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.
|
||||
|
||||
@@ -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<T>` 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.
|
||||
+29
@@ -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() {
|
||||
}
|
||||
+35
@@ -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,
|
||||
) {
|
||||
}
|
||||
+2
@@ -261,6 +261,8 @@ fun AllSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
LegalSettingsSection()
|
||||
|
||||
SettingsSection(R.string.danger_zone, isDanger = true) {
|
||||
if (hasPrivateKey) {
|
||||
SettingsItem(
|
||||
|
||||
+3
-10
@@ -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))
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-10
@@ -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)) {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
<item quantity="many">Tento příspěvek má více než %1$d hashtagů</item>
|
||||
<item quantity="other">Tento příspěvek má více než %1$d hashtagů</item>
|
||||
</plurals>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d majetek je zbalený</item>
|
||||
<item quantity="few">%1$d majetky jsou zbalený</item>
|
||||
<item quantity="many">%1$d majetků jsou zbalený</item>
|
||||
<item quantity="other">%1$d majetků jsou zbalený</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">Příspěvek se načítá nebo nemůže být nalezena ve vašem seznamu relací</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Obrázek kanálu</string>
|
||||
@@ -562,6 +568,11 @@
|
||||
<string name="profile_badges_description">Vyberte, které z odznaků, které jste obdrželi, se zobrazí na vašem profilu.</string>
|
||||
<string name="profile_badges_empty">Zatím jste neobdrželi žádné odznaky.</string>
|
||||
<string name="pictures">Obrázky</string>
|
||||
<string name="software_apps">Aplikace</string>
|
||||
<string name="route_software_apps">Aplikace</string>
|
||||
<string name="nip82_repository_label">Zdroj: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Stáhnout</string>
|
||||
<string name="calendars">Kalendáře</string>
|
||||
<string name="shorts">Krátká videa</string>
|
||||
<string name="public_chats">Veřejné chaty</string>
|
||||
@@ -1199,6 +1210,8 @@
|
||||
<string name="posting_policy">Politika příspěvků</string>
|
||||
<string name="privacy_policy">Zásady ochrany soukromí</string>
|
||||
<string name="terms_and_conditions">Podmínky & ujednání</string>
|
||||
<string name="child_safety_standards">Standardy ochrany dětí</string>
|
||||
<string name="about_legal">O aplikaci a právní informace</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
|
||||
<string name="relay_monitor_reports">Zprávy monitoru relé</string>
|
||||
@@ -1770,6 +1783,7 @@
|
||||
<string name="calendar_relative_ongoing_with_end">%1$s · končí %2$s</string>
|
||||
<string name="calendar_export_share_title">Sdílet událost kalendáře</string>
|
||||
<string name="calendar_export_event">Exportovat do kalendáře (.ics)</string>
|
||||
<string name="calendar_reminder_channel_id">calendar_reminders</string>
|
||||
<string name="calendar_reminder_channel_name">Připomenutí kalendáře</string>
|
||||
<string name="calendar_reminder_channel_description">Upozornění, když je událost, které se účastníte, blízko začátku.</string>
|
||||
<string name="calendar_reminder_default_title">Událost kalendáře</string>
|
||||
@@ -1797,6 +1811,12 @@
|
||||
<string name="calendar_reminder_settings_enabled_subtitle">Oznámení se spustí, když je událost, které se účastníte, blízko začátku.</string>
|
||||
<string name="calendar_reminder_settings_lead_title">Doba předstihu připomenutí</string>
|
||||
<string name="calendar_reminder_settings_lead_subtitle">Kolik minut před událostí chcete být upozorněni.</string>
|
||||
<plurals name="calendar_reminder_settings_lead_choice">
|
||||
<item quantity="one">%1$d min</item>
|
||||
<item quantity="few">%1$d min</item>
|
||||
<item quantity="many">%1$d min</item>
|
||||
<item quantity="other">%1$d min</item>
|
||||
</plurals>
|
||||
<string name="calendar_share_nostr">Sdílet jako Nostr odkaz</string>
|
||||
<string name="calendar_share_nostr_title">Sdílet odkaz na kalendář</string>
|
||||
<string name="calendar_filter_all">Všechny kalendáře</string>
|
||||
@@ -1828,6 +1848,7 @@
|
||||
<string name="boost_or_quote_description">Zvýšit nebo citovat</string>
|
||||
<string name="like_description">Olajkovat</string>
|
||||
<string name="zap_description">Zap</string>
|
||||
<string name="onchain_zap_description">On-chain Bitcoin zap</string>
|
||||
<string name="onchain_zap_pending">Čeká na potvrzení</string>
|
||||
<string name="change_reaction">Změnit rychlé reakce</string>
|
||||
<string name="bottom_bar_settings">Spodní navigační lišta</string>
|
||||
@@ -2788,6 +2809,7 @@
|
||||
<string name="nowhere_link_card_store">Nowhere Obchod</string>
|
||||
<string name="nowhere_link_card_petition">Nowhere Petice</string>
|
||||
<string name="nowhere_link_card_message">Nowhere Zpráva</string>
|
||||
<string name="nowhere_link_card_drop">Nowhere Drop</string>
|
||||
<string name="nowhere_link_card_art">Nowhere Umění</string>
|
||||
<string name="nowhere_link_card_forum">Nowhere Fórum</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<item quantity="one">Dieser Beitrag hat mehr als %1$d Hashtag</item>
|
||||
<item quantity="other">Dieser Beitrag hat mehr als %1$d Hashtags</item>
|
||||
</plurals>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d Asset gebündelt</item>
|
||||
<item quantity="other">%1$d Assets bündelt</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Kanalbild</string>
|
||||
@@ -556,6 +560,11 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="profile_badges_description">Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen.</string>
|
||||
<string name="profile_badges_empty">Du hast noch keine Abzeichen erhalten.</string>
|
||||
<string name="pictures">Bilder</string>
|
||||
<string name="software_apps">Apps</string>
|
||||
<string name="route_software_apps">Apps</string>
|
||||
<string name="nip82_repository_label">Quelle: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Herunterladen</string>
|
||||
<string name="calendars">Kalender</string>
|
||||
<string name="shorts">Kurzvideos</string>
|
||||
<string name="public_chats">Öffentliche Chats</string>
|
||||
@@ -1190,6 +1199,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="posting_policy">Veröffentlichungsrichtlinie</string>
|
||||
<string name="privacy_policy">Datenschutzerklärung</string>
|
||||
<string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
|
||||
<string name="child_safety_standards">Kinderschutzstandards</string>
|
||||
<string name="about_legal">Über & Rechtliches</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Fehler und Hinweise von diesem Relais</string>
|
||||
<string name="relay_monitor_reports">Relay-Überwachungsberichte</string>
|
||||
@@ -1681,6 +1692,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="edit_calendar_collection">Kalender bearbeiten</string>
|
||||
<string name="calendar_collection_events_section">Termine in diesem Kalender (%1$d)</string>
|
||||
<string name="calendar_collection_no_events_yet">Du hast noch keine Kalendertermine erstellt.</string>
|
||||
<string name="calendar_view_feed">Feed</string>
|
||||
<string name="calendar_view_month">Monat</string>
|
||||
<string name="calendar_view_week">Woche</string>
|
||||
<string name="calendar_view_day">Tag</string>
|
||||
@@ -1746,6 +1758,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="calendar_rsvp_going">Komme</string>
|
||||
<string name="calendar_rsvp_maybe">Vielleicht</string>
|
||||
<string name="calendar_rsvp_not_going">Komme nicht</string>
|
||||
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
|
||||
<string name="calendar_rsvp_none">Noch keine Antworten.</string>
|
||||
<string name="calendar_participants_section">Teilnehmer (%1$d)</string>
|
||||
<string name="calendar_event_in_calendars">In Kalendern (%1$d)</string>
|
||||
@@ -1755,6 +1768,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="calendar_relative_ongoing_with_end">%1$s · endet %2$s</string>
|
||||
<string name="calendar_export_share_title">Kalendertermin teilen</string>
|
||||
<string name="calendar_export_event">In Kalender exportieren (.ics)</string>
|
||||
<string name="calendar_reminder_channel_id">calendar_reminders</string>
|
||||
<string name="calendar_reminder_channel_name">Kalendererinnerungen</string>
|
||||
<string name="calendar_reminder_channel_description">Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt.</string>
|
||||
<string name="calendar_reminder_default_title">Kalendertermin</string>
|
||||
@@ -1991,8 +2005,10 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="git_status_closed">Geschlossen</string>
|
||||
<string name="git_status_draft">Entwurf</string>
|
||||
<string name="git_repo_tab_overview">Übersicht</string>
|
||||
<string name="git_repo_tab_issues">Tickets</string>
|
||||
<string name="git_repo_tab_patches">Patches und PRs</string>
|
||||
<string name="git_repo_section_about">Über</string>
|
||||
<string name="git_repo_section_links">Links</string>
|
||||
<string name="git_repo_section_maintainers">Maintainer</string>
|
||||
<string name="git_repo_section_topics">Themen</string>
|
||||
<string name="git_repo_personal_fork">Persönlicher Fork</string>
|
||||
@@ -2757,8 +2773,12 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="community_rules_violation_stale_rules">Das aktuelle Community-Regeldokument wurde als veraltet abgelehnt.</string>
|
||||
<!-- Nowhere links (hostednowhere.com / nowhr.xyz). The site name "Nowhere" is a proper noun, do not translate. -->
|
||||
<string name="nowhere_link_card_generic">Nowhere-Seite</string>
|
||||
<string name="nowhere_link_card_event">Nowhere Ereignis</string>
|
||||
<string name="nowhere_link_card_fundraiser">Nowhere Spendenaktion</string>
|
||||
<string name="nowhere_link_card_store">Nowhere Shop</string>
|
||||
<string name="nowhere_link_card_petition">Nowhere Petition</string>
|
||||
<string name="nowhere_link_card_message">Nowhere Nachricht</string>
|
||||
<string name="nowhere_link_card_drop">Nowhere Drop</string>
|
||||
<string name="nowhere_link_card_art">Nowhere Kunst</string>
|
||||
<string name="nowhere_link_card_forum">Nowhere Forum</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<item quantity="one">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
|
||||
<item quantity="other">Ez a bejegyzés több mint %1$d kulcsszót tartalmaz</item>
|
||||
</plurals>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d asset egybecsomagolva</item>
|
||||
<item quantity="other">%1$d asset egybecsomagolva</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">Az esemény épp betöltődik vagy nem található az átjátszólistában</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Csatorna profilképe</string>
|
||||
@@ -552,6 +556,11 @@
|
||||
<string name="profile_badges_description">Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban.</string>
|
||||
<string name="profile_badges_empty">Ön még nem kapott kitűzőt.</string>
|
||||
<string name="pictures">Képek</string>
|
||||
<string name="software_apps">Alkalmazások</string>
|
||||
<string name="route_software_apps">Alkalmazások</string>
|
||||
<string name="nip82_repository_label">Forrás: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Letöltés</string>
|
||||
<string name="calendars">Naptárak</string>
|
||||
<string name="shorts">Rövidek</string>
|
||||
<string name="public_chats">Nyilvános csevegések</string>
|
||||
@@ -1189,6 +1198,8 @@
|
||||
<string name="posting_policy">Közzétételi szabályzat</string>
|
||||
<string name="privacy_policy">Adatvédelmi irányelvek</string>
|
||||
<string name="terms_and_conditions">Általános szerződési feltételek</string>
|
||||
<string name="child_safety_standards">Gyermekbiztonsági szabványok</string>
|
||||
<string name="about_legal">Névjegy & Jogi információk</string>
|
||||
<string name="not_available_acronym">Nem érhető el</string>
|
||||
<string name="relay_error_messages">Hibák és megjegyzések ettől az átjátszótól</string>
|
||||
<string name="relay_monitor_reports">Átjátszófigyelési jelentések</string>
|
||||
|
||||
@@ -562,6 +562,11 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="profile_badges_description">Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu.</string>
|
||||
<string name="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
|
||||
<string name="pictures">Zdjęcia</string>
|
||||
<string name="software_apps">Aplikacje</string>
|
||||
<string name="route_software_apps">Aplikacje</string>
|
||||
<string name="nip82_repository_label">Źródło: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Pobierz</string>
|
||||
<string name="calendars">Kalendarze</string>
|
||||
<string name="shorts">Filmiki</string>
|
||||
<string name="public_chats">Czaty publiczne</string>
|
||||
@@ -1201,6 +1206,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="posting_policy">Polityka publikowania</string>
|
||||
<string name="privacy_policy">Polityka Prywatności</string>
|
||||
<string name="terms_and_conditions">Zasady i warunki użytkowania</string>
|
||||
<string name="child_safety_standards">Normy bezpieczeństwa dzieci</string>
|
||||
<string name="about_legal">O Nas & Informacje Prawne</string>
|
||||
<string name="not_available_acronym">Nie dotyczy</string>
|
||||
<string name="relay_error_messages">Błędy i powiadomienia z tego transmitera</string>
|
||||
<string name="relay_monitor_reports">Raporty z monitoringu transmitera</string>
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<item quantity="one">Det här inlägget har fler än %1$d hashtagg</item>
|
||||
<item quantity="other">Det här inlägget har fler än %1$d hashtaggar</item>
|
||||
</plurals>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="one">%1$d tillgång paketerad</item>
|
||||
<item quantity="other">%1$d tillgångar buntade</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">Inlägg hittades inte</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Kanal bild</string>
|
||||
@@ -550,6 +554,11 @@
|
||||
<string name="profile_badges_description">Välj vilka av märkena du fått som ska visas på din profil.</string>
|
||||
<string name="profile_badges_empty">Du har inte fått några märken ännu.</string>
|
||||
<string name="pictures">Bilder</string>
|
||||
<string name="software_apps">Appar</string>
|
||||
<string name="route_software_apps">Appar</string>
|
||||
<string name="nip82_repository_label">Källa: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">Ladda ner</string>
|
||||
<string name="calendars">Kalendrar</string>
|
||||
<string name="shorts">Kortfilmer</string>
|
||||
<string name="public_chats">Offentliga chattar</string>
|
||||
@@ -1184,6 +1193,8 @@
|
||||
<string name="posting_policy">Publiceringspolicy</string>
|
||||
<string name="privacy_policy">Integritetspolicy</string>
|
||||
<string name="terms_and_conditions">Villkor & bestämmelser</string>
|
||||
<string name="child_safety_standards">Standarder för barnsäkerhet</string>
|
||||
<string name="about_legal">Om och juridiskt</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Fel och meddelanden från detta relä</string>
|
||||
<string name="relay_monitor_reports">Reläövervakningsrapporter</string>
|
||||
@@ -1751,6 +1762,7 @@
|
||||
<string name="calendar_relative_ongoing_with_end">%1$s · slutar %2$s</string>
|
||||
<string name="calendar_export_share_title">Dela kalenderhändelse</string>
|
||||
<string name="calendar_export_event">Exportera till kalender (.ics)</string>
|
||||
<string name="calendar_reminder_channel_id">calendar_reminders</string>
|
||||
<string name="calendar_reminder_channel_name">Kalenderpåminnelser</string>
|
||||
<string name="calendar_reminder_channel_description">Påminnelse när en händelse du deltar i snart ska börja.</string>
|
||||
<string name="calendar_reminder_default_title">Kalenderhändelse</string>
|
||||
@@ -1776,6 +1788,10 @@
|
||||
<string name="calendar_reminder_settings_enabled_subtitle">En notis visas när en händelse du deltar i snart ska börja.</string>
|
||||
<string name="calendar_reminder_settings_lead_title">Påminnelsetid</string>
|
||||
<string name="calendar_reminder_settings_lead_subtitle">Hur många minuter före händelsen du vill bli notifierad.</string>
|
||||
<plurals name="calendar_reminder_settings_lead_choice">
|
||||
<item quantity="one">%1$d min</item>
|
||||
<item quantity="other">%1$d min</item>
|
||||
</plurals>
|
||||
<string name="calendar_share_nostr">Dela som Nostr-länk</string>
|
||||
<string name="calendar_share_nostr_title">Dela kalenderlänk</string>
|
||||
<string name="calendar_filter_all">Alla kalendrar</string>
|
||||
@@ -2756,5 +2772,7 @@
|
||||
<string name="nowhere_link_card_store">Nowhere Butik</string>
|
||||
<string name="nowhere_link_card_petition">Nowhere Namninsamling</string>
|
||||
<string name="nowhere_link_card_message">Nowhere Meddelande</string>
|
||||
<string name="nowhere_link_card_drop">Nowhere Drop</string>
|
||||
<string name="nowhere_link_card_art">Nowhere Konst</string>
|
||||
<string name="nowhere_link_card_forum">Nowhere Forum</string>
|
||||
</resources>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<plurals name="post_was_hidden_due_to_too_many_hashtags">
|
||||
<item quantity="other">此帖有超过 %1$d 个话题标签</item>
|
||||
</plurals>
|
||||
<plurals name="nip82_assets_count">
|
||||
<item quantity="other">捆绑了 %1$d 个资产</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">事件正在加载或无法在你的中继列表中找到</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">频道图片</string>
|
||||
@@ -546,6 +549,11 @@
|
||||
<string name="profile_badges_description">选择要在个人资料中显示的已收到的徽章。</string>
|
||||
<string name="profile_badges_empty">您还没有收到任何徽章。</string>
|
||||
<string name="pictures">图片</string>
|
||||
<string name="software_apps">应用</string>
|
||||
<string name="route_software_apps">应用</string>
|
||||
<string name="nip82_repository_label">来源: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
<string name="nip82_download">下载</string>
|
||||
<string name="calendars">日历</string>
|
||||
<string name="shorts">短视频</string>
|
||||
<string name="public_chats">公共聊天</string>
|
||||
@@ -1182,6 +1190,8 @@
|
||||
<string name="posting_policy">发布政策</string>
|
||||
<string name="privacy_policy">隐私政策</string>
|
||||
<string name="terms_and_conditions">使用条款</string>
|
||||
<string name="child_safety_standards">儿童安全标准</string>
|
||||
<string name="about_legal">关于 & 法律</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">该中继的错误和通知</string>
|
||||
<string name="relay_monitor_reports">中继监视器报告</string>
|
||||
|
||||
@@ -1318,6 +1318,8 @@
|
||||
<string name="posting_policy">Posting policy</string>
|
||||
<string name="privacy_policy">Privacy Policy</string>
|
||||
<string name="terms_and_conditions">Terms & Conditions</string>
|
||||
<string name="child_safety_standards">Child Safety Standards</string>
|
||||
<string name="about_legal">About & Legal</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Errors and Notices from this Relay</string>
|
||||
<string name="relay_monitor_reports">Relay Monitor Reports</string>
|
||||
|
||||
+57
@@ -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",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -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(
|
||||
+117
-14
@@ -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) }
|
||||
|
||||
@@ -37,7 +37,7 @@ object Base83 {
|
||||
): String {
|
||||
val buffer = CharArray(length)
|
||||
encode(value, length, buffer, 0)
|
||||
return String(buffer)
|
||||
return buffer.concatToString()
|
||||
}
|
||||
|
||||
fun encode(
|
||||
|
||||
+4
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+28
-25
@@ -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<IceCandidateData> = mutableListOf(),
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val lock = KmpLock()
|
||||
private val sessions = mutableMapOf<HexKey, SessionEntry>()
|
||||
|
||||
/** 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<HexKey> = synchronized(lock) { sessions.keys.toSet() }
|
||||
fun allSessionKeys(): Set<HexKey> = 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<IceCandidateData>
|
||||
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<SessionEntry>
|
||||
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()
|
||||
}
|
||||
|
||||
+8
-5
@@ -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<String>()
|
||||
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<String>) {
|
||||
synchronized(lock) {
|
||||
lock.withLock {
|
||||
acceptedGameIds.retainAll(keepGameIds)
|
||||
}
|
||||
}
|
||||
|
||||
+38
-27
@@ -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<JesterEvent?>(null)
|
||||
val startEvent: StateFlow<JesterEvent?> = _startEvent.asStateFlow()
|
||||
|
||||
// Move events (deduplicated by event ID)
|
||||
private val moves = ConcurrentHashMap<String, JesterEvent>()
|
||||
// Move events (deduplicated by event ID). String keys are Comparable, so
|
||||
// LargeCache (ConcurrentSkipListMap on JVM, CacheMap on Apple) works.
|
||||
private val moves = LargeCache<String, JesterEvent>()
|
||||
|
||||
// Track all processed event IDs for fast deduplication
|
||||
private val processedEventIds = ConcurrentHashMap.newKeySet<String>()
|
||||
// Track all processed event IDs for fast deduplication. A plain HashSet
|
||||
// guarded by KmpLock — simpler than a LargeCache<K, Boolean> for set-shaped
|
||||
// membership.
|
||||
private val processedEventIdsLock = KmpLock()
|
||||
private val processedEventIds = mutableSetOf<String>()
|
||||
|
||||
// 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<String, ChessEventCollector>()
|
||||
private val collectors = LargeCache<String, ChessEventCollector>()
|
||||
|
||||
/**
|
||||
* 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<String> = collectors.keys.toSet()
|
||||
fun activeGameIds(): Set<String> = 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)
|
||||
|
||||
+49
-28
@@ -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<String> =
|
||||
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<String, Long>()
|
||||
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it).
|
||||
// String keys are Comparable, so LargeCache works.
|
||||
private val recentlyLoadedGames = LargeCache<String, Long>()
|
||||
|
||||
// Dedup incoming events (same event delivered by multiple relays)
|
||||
// Bounded LRU: evict oldest when exceeding capacity
|
||||
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
|
||||
// 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<String>()
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-9
@@ -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<String?>(null)
|
||||
val selectedGameId: StateFlow<String?> = _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<Long> = _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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-14
@@ -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<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS,
|
||||
@@ -74,16 +79,22 @@ class ChessRelayFetchHelper(
|
||||
): List<Event> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
|
||||
val events = ConcurrentHashMap<String, Event>()
|
||||
val events = LargeCache<String, Event>()
|
||||
val relayCount = filters.keys.size
|
||||
val eoseReceived = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
|
||||
val relayEventCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
|
||||
// Per-relay event counters. Each counter is independently atomic, so
|
||||
// increments don't need to lock the whole map.
|
||||
val relayEventCounts = LargeCache<NormalizedRelayUrl, AtomicInt>()
|
||||
// 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<NormalizedRelayUrl>()
|
||||
val allEose = CompletableDeferred<Unit>()
|
||||
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<Filter>?,
|
||||
) {
|
||||
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<Filter>?,
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+32
-45
@@ -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<CharArray>(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<Int, List<Int>> {
|
||||
val bytes = mutableListOf<Int>()
|
||||
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<Int>()
|
||||
|
||||
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<Int>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -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 {
|
||||
|
||||
+84
-61
@@ -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<FeedDefinition>): 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<FeedDefinition> {
|
||||
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<FeedDefinition> {
|
||||
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<ObjectNode>("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<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
|
||||
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
|
||||
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
|
||||
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
|
||||
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
|
||||
set<ArrayNode>("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<String>): 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()
|
||||
}
|
||||
|
||||
@@ -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<NormalizedRelayUrl, Counter>()
|
||||
|
||||
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = 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<MutableSharedFlow<ListChange<Note>>>? = null
|
||||
|
||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||
val current = changesFlow.get()
|
||||
val current = changesFlow?.get()
|
||||
if (current != null) return current
|
||||
val new = MutableSharedFlow<ListChange<Note>>(0, 10, BufferOverflow.DROP_OLDEST)
|
||||
changesFlow = WeakReference(new)
|
||||
@@ -64,23 +70,20 @@ abstract class Channel : NotesGatherer {
|
||||
abstract fun toBestDisplayName(): String
|
||||
|
||||
open fun relays(): Set<NormalizedRelayUrl> =
|
||||
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) {
|
||||
|
||||
@@ -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<Note, Note?>()
|
||||
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) {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+69
-64
@@ -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<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
|
||||
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
|
||||
|
||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||
val current = changesFlow.get()
|
||||
val current = changesFlow?.get()
|
||||
if (current != null) return current
|
||||
val new = MutableSharedFlow<ListChange<Note>>(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<Note>(toRemove))
|
||||
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(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<Note> {
|
||||
val toRemove = messages
|
||||
if (toRemove.isEmpty()) return toRemove
|
||||
messages = emptySet()
|
||||
newestMessage = null
|
||||
unreadCount.value = 0
|
||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
||||
return toRemove
|
||||
}
|
||||
fun clearAllMessagesSync(): Set<Note> =
|
||||
syncLock.withLock {
|
||||
val toRemove = messages
|
||||
if (toRemove.isEmpty()) return@withLock toRemove
|
||||
messages = emptySet()
|
||||
newestMessage = null
|
||||
unreadCount.value = 0
|
||||
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
||||
toRemove
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -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<NormalizedRelayUrl, RelayInfo> = 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<MutableStateFlow<Wrapper>>? = null
|
||||
private val flowLock = KmpLock()
|
||||
|
||||
fun flow() =
|
||||
flow?.get() ?: synchronized(this) {
|
||||
flow?.get() ?: flowLock.withLock {
|
||||
flow?.get() ?: MutableStateFlow(Wrapper(data)).also { flow = WeakReference(it) }
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-1
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+22
-35
@@ -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<Note> =
|
||||
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<Note> =
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> 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<Note> {
|
||||
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<AddressableNote> {
|
||||
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 }
|
||||
}
|
||||
|
||||
+67
-62
@@ -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<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
|
||||
// Per-instance lock shared by previously @Synchronized methods.
|
||||
private val syncLock = KmpLock()
|
||||
|
||||
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>>? = null
|
||||
|
||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||
val current = changesFlow.get()
|
||||
val current = changesFlow?.get()
|
||||
if (current != null) return current
|
||||
val new = MutableSharedFlow<ListChange<Note>>(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<HexKey>): 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<Note>(toRemove))
|
||||
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
|
||||
|
||||
return toRemove
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+13
-9
@@ -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<T> :
|
||||
ComposeSubscriptionManagerControls,
|
||||
Subscribable<T> {
|
||||
private var composeSubscriptions: ConcurrentHashMap<T, T> = 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<T, T>()
|
||||
|
||||
// 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<T> :
|
||||
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<T> :
|
||||
override fun subscribe(query: List<T>) {
|
||||
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<T> :
|
||||
override fun unsubscribe(query: List<T>) {
|
||||
if (query.isEmpty()) return
|
||||
|
||||
query.forEach {
|
||||
composeSubscriptions.remove(it)
|
||||
lock.withLock {
|
||||
query.forEach { composeSubscriptions.remove(it) }
|
||||
}
|
||||
|
||||
invalidateKeys()
|
||||
}
|
||||
|
||||
fun allKeys() = composeSubscriptions.keys
|
||||
fun allKeys(): Set<T> = lock.withLock { composeSubscriptions.keys.toSet() }
|
||||
}
|
||||
|
||||
interface Subscribable<T> {
|
||||
|
||||
+23
-12
@@ -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<T : MutableQueryState>(
|
||||
val scope: CoroutineScope,
|
||||
) : ComposeSubscriptionManagerControls {
|
||||
private var composeSubscriptions: ConcurrentHashMap<T, Job?> = 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<T, Job>()
|
||||
|
||||
// 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<T : MutableQueryState>(
|
||||
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<T> = lock.withLock { composeSubscriptions.keys.toSet() }
|
||||
|
||||
fun forEachSubscriber(action: (T) -> Unit) {
|
||||
composeSubscriptions.keys.forEach(action)
|
||||
val snapshot = lock.withLock { composeSubscriptions.keys.toList() }
|
||||
snapshot.forEach(action)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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<T>(
|
||||
val client: INostrClient,
|
||||
|
||||
+13
-11
@@ -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<K : Any>(
|
||||
private val maxSize: Int = 200,
|
||||
) {
|
||||
private val cache = linkedMapOf<K, EOSERelayList>()
|
||||
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<K : Any>(
|
||||
}
|
||||
|
||||
fun since(key: K): SincePerRelayMap? =
|
||||
synchronized(lock) {
|
||||
lock.withLock {
|
||||
cache[key]?.relayList?.toMutableMap()
|
||||
}
|
||||
|
||||
@@ -68,18 +70,18 @@ open class EOSECache<K : Any>(
|
||||
) = 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<K1 : Any, K2 : Any>(
|
||||
private val innerMaxSize: Int = 200,
|
||||
) {
|
||||
private val cache = linkedMapOf<K1, EOSECache<K2>>()
|
||||
private val lock = Any()
|
||||
private val lock = KmpLock()
|
||||
|
||||
fun addOrUpdate(
|
||||
outerKey: K1,
|
||||
@@ -99,7 +101,7 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
|
||||
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<K1 : Any, K2 : Any>(
|
||||
outerKey: K1,
|
||||
innerKey: K2,
|
||||
): SincePerRelayMap? =
|
||||
synchronized(lock) {
|
||||
lock.withLock {
|
||||
cache[outerKey]?.since(innerKey)
|
||||
}
|
||||
|
||||
@@ -131,13 +133,13 @@ open class EOSETwoLevelCache<K1 : Any, K2 : Any>(
|
||||
) = addOrUpdate(outerKey, innerKey, relayUrl, time)
|
||||
|
||||
fun removeOuter(key: K1) {
|
||||
synchronized(lock) {
|
||||
lock.withLock {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
lock.withLock {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-42
@@ -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)
|
||||
|
||||
+2
-18
@@ -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)
|
||||
|
||||
+1
-1
@@ -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))
|
||||
|
||||
+8
-5
@@ -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<String>()
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+7
-9
@@ -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<Long, DoubleArray>()
|
||||
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 } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+9
-6
@@ -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>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <T> KmpLock.withLock(block: () -> T): T {
|
||||
lock()
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
unlock()
|
||||
}
|
||||
}
|
||||
+31
@@ -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
|
||||
+34
@@ -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<T : Any>(
|
||||
referent: T,
|
||||
) {
|
||||
fun get(): T?
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+65
@@ -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()
|
||||
|
||||
+92
@@ -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<NormalizedRelayUrl>(), 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)
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -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"))
|
||||
|
||||
+152
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+44
@@ -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<String, Set<String>>()
|
||||
|
||||
actual companion object {
|
||||
actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage()
|
||||
}
|
||||
|
||||
actual fun load(userPubkey: String): Set<String> = dismissed[userPubkey] ?: emptySet()
|
||||
|
||||
actual fun save(
|
||||
userPubkey: String,
|
||||
ids: Set<String>,
|
||||
) {
|
||||
if (ids.isEmpty()) {
|
||||
dismissed.remove(userPubkey)
|
||||
} else {
|
||||
dismissed[userPubkey] = ids
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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)")
|
||||
}
|
||||
+54
@@ -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()))
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+37
@@ -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()
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
+31
@@ -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<T : Any> actual constructor(
|
||||
referent: T,
|
||||
) {
|
||||
private val ref = kotlin.native.ref.WeakReference(referent)
|
||||
|
||||
actual fun get(): T? = ref.get()
|
||||
}
|
||||
@@ -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
|
||||
+72
@@ -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)
|
||||
+31
@@ -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()
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
+23
@@ -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<T> = java.lang.ref.WeakReference<T>
|
||||
@@ -1043,6 +1043,7 @@ fun App(
|
||||
onDismiss = onDismissComposeDialog,
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
localCache = localCache,
|
||||
replyTo = replyToNote,
|
||||
)
|
||||
}
|
||||
|
||||
+56
@@ -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<String, RichTextViewerState>(64, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<String, RichTextViewerState>) = size > MAX_CACHE_SIZE
|
||||
},
|
||||
)
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
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("](")
|
||||
}
|
||||
+135
-17
@@ -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<List<User>>(emptyList()) }
|
||||
var mentionQuery by remember { mutableStateOf<String?>(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<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachedFiles = remember { mutableStateListOf<File>() }
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user