Merge upstream/main into worktree-desktop-low-hanging-fruit

This commit is contained in:
nrobi144
2026-05-25 06:45:44 +03:00
332 changed files with 26634 additions and 2386 deletions
+23
View File
@@ -306,6 +306,29 @@ actual fun openExternalUrl(url: String) {
}
```
## Icons
The Material Symbols font bundled at
`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
is a **subset** that only contains the glyphs referenced from
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
**MANDATORY:** Whenever you add a new icon — i.e. introduce a
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
`MaterialSymbols.kt` — you MUST regenerate the subset font by running:
```bash
./tools/material-symbols-subset/subset.sh
```
Commit the regenerated `material_symbols_outlined.ttf` alongside your
`MaterialSymbols.kt` change. Without this step the new icon renders as tofu (□)
at runtime because the glyph is not in the bundled font.
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
regenerating. See `tools/material-symbols-subset/README.md` for details and
prerequisites (`pip install fonttools brotli`).
## Code Formatting
After completing any task that modifies Kotlin files, always run:
```
+2 -2
View File
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 446
versionName = "1.10.0"
versionCode = 447
versionName = "1.11.0"
vectorDrawables {
useSupportLibrary = true
@@ -74,7 +74,14 @@ Before presenting results, **scan the missing English strings** for two red-flag
1. **Hardcoded `"1"` next to a noun.** A new English string like `"1 reply"`, `"1 follower"`, or `"1 minute ago"` almost always belongs in a `<plurals>` resource — not a `<string>`. Hardcoding `1` in English forces every translator to either also hardcode `1` (breaking languages where the `one` category covers other numbers, e.g. some Slavic languages) or to silently change the meaning.
2. **A `%d` / `%1$d` placeholder in a clearly singular/plural sentence** (e.g. `"%1$d reply"`, `"%d follower"`). Even though the placeholder is parameterised, English-only `one`/`other` agreement won't survive translation into languages that need `few`/`many`.
Also **audit existing `<plurals>` resources** for the same anti-pattern — any locale's `quantity="one"` item that hardcodes the literal `1` (instead of using a `%d` / `%1$d` placeholder) is broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.). Flag and offer to fix:
Also **audit existing `<plurals>` resources** for two anti-patterns:
1. **`quantity="one"` items that hardcode the literal `1`** (instead of using a `%d` / `%1$d` placeholder) — broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.).
2. **`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. **everything except Arabic (`ar`) and Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and all the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item.
Flag and offer to fix:
```bash
# Scan every locale's strings.xml for <item quantity="one"> entries that
@@ -96,6 +103,27 @@ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*
done
```
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
# Skip Arabic and Welsh — they natively use the zero category.
case "$f" in
*values-ar*|*values-cy*) continue ;;
esac
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="zero"/ {
text = $0; sub(/^[^>]*>/, "", text); sub(/<.*$/, "", text)
print file ": <plurals name=\"" name "\"> zero=\"" text "\""
}
/<\/plurals>/ { in_plurals = 0 }
' "$f"
done
```
For each hit, warn the user that the entry is unreachable in that locale. The fix is to **remove the `<item quantity="zero">`** and, if the UX wanted distinct wording for count=0, add a separate `<string>` plus an `if (count == 0)` branch at the call site (see "Plurals: handle with care" below).
Quick scan over the missing keys:
```bash
@@ -147,6 +175,14 @@ When adding or proposing **`<plurals>`** entries, follow these rules:
- Arabic (`ar`): `zero`, `one`, `two`, `few`, `many`, `other`
- German / Swedish / Brazilian Portuguese: `one`, `other`
- When a missing string contains a count placeholder and is conceptually a singular/plural pair, **flag it before translating** — it may belong as a `<plurals>` resource rather than a single `<string>`. Surface this to the user before proposing translations.
- **Do not use `quantity="zero"` outside Arabic (`ar`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those two languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
```kotlin
val label = if (count == 0) {
stringRes(R.string.foo_no_items, dateLabel)
} else {
pluralStringResource(R.plurals.foo_items, count, dateLabel, count)
}
```
- Reference: [Android `<plurals>` docs](https://developer.android.com/guide/topics/resources/string-resource#Plurals) and [CLDR plural rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html).
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
@@ -165,4 +201,5 @@ When adding translated strings to locale files:
- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
- **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`)
- **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`)
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.10.0` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.11.0` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.10.0"
quartz = "1.11.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.10.0")
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.10.0
com.vitorpamplona.quartz:quartz:1.11.0
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.10.0"
quartz = "1.11.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.10.0")
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.10.0")
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.10.0
com.vitorpamplona.quartz:quartz:1.11.0
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+13 -1
View File
@@ -155,8 +155,20 @@ jobs:
;;
esac
# Compose UI smoke test (DesktopLaunchSmokeTest) uses Skiko which needs
# a display server on Linux. xvfb provides a virtual framebuffer.
- name: Install xvfb (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Test + Build Desktop (gradle)
run: ./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$CMD
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu
+173
View File
@@ -0,0 +1,173 @@
name: Desktop Smoke Test
on:
workflow_dispatch:
pull_request:
paths:
- 'desktopApp/**'
- '.github/workflows/smoke-test-desktop.yml'
permissions:
contents: read
concurrency:
group: smoke-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
# -------------------------------------------------------------------------
# 1) Compose UI test — verifies the composable tree renders (login screen)
# under the dev classpath. Catches missing string resources, broken
# composables, and basic dependency-graph issues.
# -------------------------------------------------------------------------
compose-ui-test:
runs-on: ubuntu-latest
timeout-minutes: 30
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: true
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run desktop tests (including UI smoke test)
run: xvfb-run --auto-servernum ./gradlew :desktopApp:test
# -------------------------------------------------------------------------
# 2) Release .deb build + launch — builds the ProGuard'd, jlink'd .deb
# package, installs it, and verifies the process stays alive for 10s.
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
# (java.management, java.prefs), and native lib bundling issues.
# -------------------------------------------------------------------------
release-deb-launch:
runs-on: ubuntu-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: true
- name: Cache vlc-setup downloads
uses: actions/cache@v4
with:
path: ~/.gradle/vlcSetup
key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }}
restore-keys: |
vlcsetup-Linux-
- name: Pre-fetch VLC + UPX archives
env:
VLC_VERSION: "3.0.20"
UPX_VERSION: "4.2.4"
run: |
set -euo pipefail
DEST="$HOME/.gradle/vlcSetup"
mkdir -p "$DEST"
fetch() {
local url="$1" out="$2"
if [[ -s "$out" ]]; then echo "cached: $out"; return 0; fi
echo "fetching: $url"
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
--retry-max-time 900 --connect-timeout 30 \
-o "$out.part" "$url"
mv "$out.part" "$out"
}
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
"$DEST/vlc-${VLC_VERSION}.jar"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
"$DEST/upx-${UPX_VERSION}.tar.xz"
- name: Install xvfb + packaging deps
run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot
- name: Build release .deb
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 20
command: ./gradlew --no-daemon :desktopApp:packageReleaseDeb
- name: Relax libicu dependency
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
- name: Install .deb
run: |
# jpackage's post-install script runs xdg-desktop-menu which fails
# on CI runners ("No writable system menu directory"). The files are
# extracted successfully; only the menu registration fails. Allow the
# dpkg error, then verify the binary was actually installed.
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
echo "Installed files:"
dpkg -L amethyst | head -30
# Fail if the binary wasn't actually extracted
test -d /opt/amethyst || test -d /opt/Amethyst || {
echo "FAIL: /opt/amethyst not found after dpkg -i"
exit 1
}
- name: Smoke test — app launches and stays alive
run: |
set -euo pipefail
# Find the launcher binary
LAUNCHER=$(find /opt -name "Amethyst" -type f -executable 2>/dev/null | head -1)
if [[ -z "$LAUNCHER" ]]; then
# Fallback: search dpkg file list
LAUNCHER=$(dpkg -L amethyst | grep -E '/bin/[Aa]methyst$' | head -1)
fi
if [[ -z "$LAUNCHER" ]]; then
echo "FAIL: could not find Amethyst launcher binary"
dpkg -L amethyst
exit 1
fi
echo "Launcher: $LAUNCHER"
# Launch under xvfb with a timeout safety net
xvfb-run --auto-servernum timeout 30 "$LAUNCHER" &
APP_PID=$!
echo "PID: $APP_PID"
# Wait 10s — if the process is still alive, the app launched successfully
sleep 10
if kill -0 "$APP_PID" 2>/dev/null; then
echo "PASS: Application launched and stayed alive for 10s"
kill "$APP_PID" || true
wait "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null
EXIT_CODE=$?
echo "FAIL: Application exited with code $EXIT_CODE within 10s"
exit 1
fi
- name: Upload .deb artifact (for manual testing)
if: always()
uses: actions/upload-artifact@v7
with:
name: Release DEB (smoke-tested)
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
+9 -9
View File
@@ -113,7 +113,7 @@ Where:
| Field | Values |
|---|---------------------------------------------------------|
| `<version>` | Tag stripped of leading `v` (e.g. `1.10.0`) |
| `<version>` | Tag stripped of leading `v` (e.g. `1.11.0`) |
| `<family>` | `macos`, `windows`, `linux` |
| `<arch>` | `x64`, `arm64` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
@@ -124,10 +124,10 @@ any change is a breaking contract.
Examples:
- `amethyst-desktop-1.10.0-macos-x64.dmg`
- `amethyst-desktop-1.10.0-macos-arm64.dmg`
- `amethyst-desktop-1.10.0-windows-x64.msi`
- `amethyst-desktop-1.10.0-linux-x64.AppImage`
- `amethyst-desktop-1.11.0-macos-x64.dmg`
- `amethyst-desktop-1.11.0-macos-arm64.dmg`
- `amethyst-desktop-1.11.0-windows-x64.msi`
- `amethyst-desktop-1.11.0-linux-x64.AppImage`
---
@@ -147,7 +147,7 @@ Quartz library in one pipeline.
must increment even for same `versionName`):
```groovy
versionCode = 443
versionCode = 447
versionName = generateVersionName(libs.versions.app.get())
```
@@ -223,8 +223,8 @@ or equivalent issue tracker. On rotation, paste new token and run
```bash
brew bump-cask-pr amethyst-nostr \
--version 1.10.0 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.10.0/amethyst-desktop-1.10.0-macos-arm64.dmg"
--version 1.11.0 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-macos-arm64.dmg"
```
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
@@ -235,7 +235,7 @@ auto-submits new version bumps on each stable release.
```bash
wingetcreate new \
https://github.com/vitorpamplona/amethyst/releases/download/v1.10.0/amethyst-desktop-1.10.0-windows-x64.msi
https://github.com/vitorpamplona/amethyst/releases/download/v1.11.0/amethyst-desktop-1.11.0-windows-x64.msi
```
Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is
+105
View File
@@ -1,3 +1,108 @@
<a id="v1.11.0"></a> NIP-52 Calendars, On-Chain Zap Polish
Highlights:
- NIP-52 Calendars — feed, day/week/month views, event/collection editors, RSVP, ICS export, "starting soon" notifications, share-as-nostr-link, gallery picker.
- On-Chain zaps polish — splits with dust gating, payment-targets modal redesign (QR + copy + pay), public-address chip + confirmation, on-chain handoff from custom-zap dialog, on-chain zappers in the reactions gallery.
- Privacy groundwork — I2P scaffolded as a parallel transport to Tor, route-aware HTTP stack, typed fail-closed `BlockedRouteException`.
- Notifications — dedicated settings screen, delivery vs display split, on-chain zaps in summary stats, push filters aligned with the Notifications feed.
- Inline nowhere-link cards in rich text.
- Wallet — clickable on-chain rows, tappable wallet card opens transaction history, reactive on-chain zap observation, time-windowed relay sub with burst coalescing.
PRs:
- Restore Lightning Address + LNURL fields in Edit Profile
- Raise the default maximum hashtag limit from 5 to 8
- Include on-chain zaps in notification summary stats
- Add electrum.nmc.ethicnology.com to default ElectrumX server set
- Translate call-permission and git-repo strings (cs, pt-BR, sv, de) + New Crowdin translations
- Subscribe and observe PaymentTargetsEvent for other users
- Only bridge to local cache when URL is BUD-01 layout; dedupe public-channel rows; normalize video/x-m4v to video/mp4
- Enable Send when typed name resolves via NIP-05; on-chain Bit recipient
- Inline Namecoin resolution indicator + result row on on-chain zaps
- Inline Namecoin resolution indicator in global search bar
- Long-press to copy Nostr Address, Website, LN Address, identities, payment targets
- macOS VLC bundled discovery and video rendering
- Make RelayAuthenticator authStatus thread-safe
- Pad GitRepositoryOverview content to clear top/bottom bars
- Use LargeCache for RelayAuthenticator authStatus
- MIME-type normalization for MediaStore
- Split delivery vs display, add Categories section in notifications
- Accept floating-point dimensions in imeta so image space is reserved pre-load
- Explain hashtag-limit hide reason in HiddenNote
- Route media-upload signing through launchSigner
- Render nowhere links inline as branded cards
- Release MediaController after 30s in background; skip timer for PiP
- Clickable on-chain card opens transaction history; tappable on-chain rows + txid index in LocalCache
- Disable ProGuard optimization entirely on desktop (fixes kmp-tor crash); .deb launch CI smoke test
- Show on-chain zappers in expanded reactions gallery
- Convert hashtag-limit message to <plurals> and add cs/pt-BR/sv/de translations
- Restore long-press on root note + anchor popup to the card
- Keep filter chips visible on empty on-chain transactions list
- Make playback notification tap open the note on warm-pool resume
- Validate zap receipts against LNURL provider's nostrPubkey (NIP-57 Appendix F)
- Stable sort in ShortsFeedFilter to avoid TimSort contract crash
- Align push notifications with Notifications feed filter; resolve addressable events to their replaceable note; WakeUp bypass + lookup hoist
- Update on-chain-zap + headers-explorer plans for NIP-BC inline SPV tags
- Start zoomable dialog close animation from zoomed bounds
- Scaffold I2P as a parallel privacy transport to Tor; route-aware HTTP stack; BlockedRouteException
- Reorder feed filter popup sections (DVMs before Communities, interest sets next to hashtags)
- Stop spinning Around Me when Geocoder is unavailable
- NIP-52 calendars — feed, day/week/month views, RSVP, ICS export, reminders, edit, share-as-nostr-link, participant picker, multi-day bars
- Audit follow-ups on MLS reply paths; route reply button on MLS messages to the encrypted group
- Use LaunchedEffect instead of remember for VM reset
- Switch to Dispatchers.IO around the whole executeAsync call in relay-info
- Drop crossfade around the card feed in notifications
- Observe locale in CalendarDateTimePickerButton
- Modernize zap amount choice popup to match reactions popup style
- Catch ForegroundServiceStartNotAllowedException in PlaybackService
- Include crashing thread name in crash report
- Convert remaining .gradle files to Kotlin DSL
- Redesign payment targets modal with QR, copy and pay buttons
- Add Public chip to on-chain wallet card; clarify privacy guidance on on-chain wallet popup
- Require running material-symbols-subset.sh when adding new icons; add new information icon
- Re-add Nostrcheck.me Blossom server to defaults
- Surface on-chain zaps from the reactions zap button
- On-chain zap splits — drop sender from splits, merge duplicates, gate Send on dust
- On-chain option on the Zap the Devs button
- On-chain handoff from the custom-zap dialog
- Keep listening on default index+search relays for users with no kind 10002
- Skip offline relays when fetching user metadata
- Include indexer and proxy relays in the trusted relay list to avoid Tor
- Confirm on-chain copy with public-address warning
- Swallow LegacyCursorAnchorInfo IllegalArgumentException
- Render ContactListEvent in NoteCompose with tap-through user list; add preview for DisplayContactList
## Contributors
- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z
- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- @npub1dn0tej4a5806qk9ts56j572sndvjk27l5qmsxf0z3mquknccve7s4k8tfp
## Translations
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz
- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h
- Russian by Anton Zhao
<a id="v1.10.0"></a> On-Chain zaps
- ci: remove macos-13 x64 build legs by @vitorpamplona in #2939
- Replace linuxdeploy with appimagetool for AppImage packaging by @vitorpamplona in #2940
- Sync inner note highlight fade with parent background color by @vitorpamplona in #2941
- Handle denied call permissions with settings dialog by @vitorpamplona in #2942
- Fix gradient rendering with transparent background colors by @vitorpamplona in #2943
- Add NIP-BC onchain Bitcoin zaps support (send, receive, display) by @vitorpamplona in #2944
<a id="v1.09.2"></a> Fixes
- Fix Blossom blob detection to reject non-compliant filenames by @greenart7c3 in #2919
+86 -26
View File
@@ -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.
-435
View File
@@ -1,435 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
def getCurrentBranch() {
try {
def branch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim()
return branch
} catch (Exception e) {
println "Could not determine git branch: ${e.message}"
return "unknown"
}
}
def generateVersionName(String baseVersion) {
def currentBranch = getCurrentBranch()
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
return baseVersion
} else {
// Clean branch name for version (replace special characters)
def cleanBranch = currentBranch.replaceAll(/[^a-zA-Z0-9\-_]/, "-")
// Limit branch name to maximum 20 characters
if (cleanBranch.length() > 20) {
cleanBranch = cleanBranch.substring(0, 20)
}
return "${baseVersion}-${cleanBranch}"
}
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
stabilityNames.each { scName ->
compileNames.each { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 446
versionName = generateVersionName(libs.versions.app.get())
buildConfigField "String", "RELEASE_NOTES_ID", "\"b1b91d7ee0c5da9d081d1a53470248ee4585b058b11aa34fe28c0e3e07ac1e0a\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
resourceConfigurations += [
'ar',
'ar-rSA',
'bn-rBD',
'cs',
'cs-rCZ',
'cy-rGB',
'da-rDK',
'de',
'de-rDE',
'el-rGR',
'en-rGB',
'eo',
'eo-rUY',
'es',
'es-rES',
'es-rMX',
'es-rUS',
'et-rEE',
'fa',
'fa-rIR',
'fi-rFI',
'fo-rFO',
'fr',
'fr-rCA',
'fr-rFR',
'gu-rIN',
'hi-rIN',
'hr-rHR',
'hu',
'hu-rHU',
'in',
'in-rID',
'it-rIT',
'iw-rIL',
'ja',
'ja-rJP',
'kk-rKZ',
'ko-rKR',
'ks-rIN',
'ku-rTR',
'lt-rLT',
'ne-rNP',
'nl',
'nl-rBE',
'nl-rNL',
'pcm-rNG',
'pl-rPL',
'pt-rBR',
'pt-rPT',
'ru',
'ru-rRU',
'ru-rUA',
'sa-rIN',
'sl-rSI',
'so-rSO',
'sr-rSP',
'ss-rZA',
'sv-rSE',
'sw-rKE',
'sw-rTZ',
'ta',
'ta-rIN',
'th',
'th-rTH',
'tr',
'tr-rTR',
'uk',
'uk-rUA',
'ur-rIN',
'uz-rUZ',
'vi-rVN',
'zh',
'zh-rCN',
'zh-rHK',
'zh-rSG',
'zh-rTW'
]
}
// Opt-in fast-build flags. Default behavior is unchanged.
//
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
// APK per (flavor, buildType) instead of 5.
// Cuts ~600 MB of intermediates and several
// minutes off CI.
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
// extra universal APK output. (No effect
// when disableAbiSplits is also set, since
// there are no splits to add to.)
// -Pamethyst.skipMapping=true disable R8 minification on release and
// benchmark. APK is larger, but builds are
// much faster and outputs/mapping/ (~260MB)
// is not produced. Local-dev and PR-CI use
// only — release pipelines must not set it.
def disableAbiSplits = providers.gradleProperty("disableAbiSplits")
.map { it.toBoolean() }.getOrElse(false)
def disableUniversalApk = providers.gradleProperty("disableUniversalApk")
.map { it.toBoolean() }.getOrElse(false)
def skipMapping = providers.gradleProperty("amethyst.skipMapping")
.map { it.toBoolean() }.getOrElse(false)
buildTypes {
release {
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled = !skipMapping
}
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
create("benchmark") {
initWith(getByName("release"))
applicationIdSuffix '.benchmark'
versionNameSuffix '-BENCHMARK'
resValue "string", "app_name", "@string/app_name_benchmark"
profileable = true
signingConfig = signingConfigs.debug
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
resources.pickFirsts.add('builddef.lst')
resources.pickFirsts.add('META-INF/LICENSE.md')
resources.pickFirsts.add('META-INF/LICENSE-notice.md')
}
}
flavorDimensions = ["channel"]
productFlavors {
play {
getIsDefault().set(true)
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "true"
}
fdroid {
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "false"
}
}
splits {
abi {
enable = !disableAbiSplits
reset()
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
universalApk = !disableUniversalApk
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packagingOptions {
resources {
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
}
}
lint {
disable 'MissingTranslation'
}
testOptions {
unitTests.returnDefaultValues = true
}
}
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
def tink = "com.google.crypto.tink:tink-android:1.17.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
substitute module('com.google.crypto.tink:tink') using module(tink)
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation platform(libs.androidx.compose.bom)
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation project(path: ':nestsClient')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
implementation libs.androidx.ui
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
// Needs this to open gallery / image upload
implementation libs.androidx.fragment.ktx
// Navigation
implementation libs.androidx.navigation.compose
// Material 3 Design
implementation libs.androidx.material3
// Adaptive Layout / Two Pane
implementation libs.androidx.material3.windowSize
implementation libs.accompanist.adaptive
// Lifecycle
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.lifecycle.runtime.compose
implementation libs.androidx.lifecycle.viewmodel.compose
// Zoomable images
implementation libs.zoomable
// Biometrics
implementation libs.androidx.biometric.ktx
// Background Work
implementation libs.androidx.work.runtime.ktx
// Websockets API
implementation libs.okhttp
implementation libs.okhttpCoroutines
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx
implementation libs.androidx.datastore.preferences
// view videos
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.exoplayer.hls
implementation libs.androidx.media3.ui.compose.material3
implementation libs.androidx.media3.session
// important for proxy / tor
implementation libs.androidx.media3.datasource.okhttp
// Load images from the web.
implementation libs.coil.compose
// view gifs
implementation libs.coil.gif
// view svgs
implementation libs.coil.svg
// enables network for coil
implementation libs.coil.okhttp
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation libs.accompanist.permissions
// For QR generation
implementation libs.zxing
implementation libs.zxing.embedded
// Markdown
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation libs.markdown.ui
implementation libs.markdown.ui.material3
implementation libs.markdown.commonmark
// Language picker and Theme chooser
implementation libs.androidx.appcompat
// Dynamically adjust between phone and tablet UI
implementation libs.androidx.window.core.android
// Local model for language identification
playImplementation libs.google.mlkit.language.id
// Google services model the translate text
playImplementation libs.google.mlkit.translate
// On-device AI writing assistance (Gemini Nano via AICore)
playImplementation libs.google.mlkit.genai.proofreading
playImplementation libs.google.mlkit.genai.prompt
playImplementation libs.google.mlkit.genai.rewriting
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
// with image-labeling as a keyword-join fallback for devices without AICore.
playImplementation libs.google.mlkit.genai.image.description
// PushNotifications
playImplementation platform(libs.firebase.bom)
playImplementation libs.firebase.messaging
//PushNotifications(FDroid)
fdroidImplementation libs.unifiedpush
// Google Cast SDK — Chromecast support. Play flavor only because the
// framework hard-depends on Google Play services, which is unavailable
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
playImplementation libs.play.services.cast.framework
// Charts
implementation libs.vico.charts.compose
implementation libs.vico.charts.m3
// Waveform visualizer
implementation libs.audiowaveform
// Video compression lib
implementation libs.abedElazizShe.video.compressor.fork
// Image compression lib
implementation libs.zelory.image.compressor
// EXIF metadata stripping
implementation libs.androidx.exifinterface
// Voice anonymization DSP
implementation libs.tarsosdsp
// WebRTC for voice/video calls
implementation libs.stream.webrtc.android
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation libs.androidx.ui.test.junit4
androidTestImplementation libs.mockk.android
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
implementation libs.androidx.camera.core
implementation libs.androidx.camera.camera2
implementation libs.androidx.camera.lifecycle
implementation libs.androidx.camera.view
implementation libs.androidx.camera.extensions
}
+463
View File
@@ -0,0 +1,463 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
fun getCurrentBranch(): String =
try {
val process =
ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
.redirectErrorStream(true)
.start()
val branch =
process.inputStream
.bufferedReader()
.use { it.readText() }
.trim()
process.waitFor()
branch
} catch (e: Exception) {
println("Could not determine git branch: ${e.message}")
"unknown"
}
fun generateVersionName(baseVersion: String): String {
val currentBranch = getCurrentBranch()
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
return baseVersion
}
// Clean branch name for version (replace special characters)
var cleanBranch = currentBranch.replace(Regex("[^a-zA-Z0-9\\-_]"), "-")
// Limit branch name to maximum 20 characters
if (cleanBranch.length > 20) {
cleanBranch = cleanBranch.substring(0, 20)
}
return "$baseVersion-$cleanBranch"
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
val stabilityNames = tasks.names.filter { it.contains("StabilityCheck") }
val compileNames = tasks.names.filter { it.matches(Regex("compile.*UnitTestKotlin")) }
stabilityNames.forEach { scName ->
compileNames.forEach { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = "com.vitorpamplona.amethyst"
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
targetSdk =
libs.versions.android.targetSdk
.get()
.toInt()
versionCode = 447
versionName = generateVersionName(libs.versions.app.get())
buildConfigField("String", "RELEASE_NOTES_ID", "\"8ec0d94550b5538115226c6858159b1115713c9c6ed942173bd4fd5d292d8ba6\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
@Suppress("UnstableApiUsage")
resourceConfigurations +=
listOf(
"ar",
"ar-rSA",
"bn-rBD",
"cs",
"cs-rCZ",
"cy-rGB",
"da-rDK",
"de",
"de-rDE",
"el-rGR",
"en-rGB",
"eo",
"eo-rUY",
"es",
"es-rES",
"es-rMX",
"es-rUS",
"et-rEE",
"fa",
"fa-rIR",
"fi-rFI",
"fo-rFO",
"fr",
"fr-rCA",
"fr-rFR",
"gu-rIN",
"hi-rIN",
"hr-rHR",
"hu",
"hu-rHU",
"in",
"in-rID",
"it-rIT",
"iw-rIL",
"ja",
"ja-rJP",
"kk-rKZ",
"ko-rKR",
"ks-rIN",
"ku-rTR",
"lt-rLT",
"ne-rNP",
"nl",
"nl-rBE",
"nl-rNL",
"pcm-rNG",
"pl-rPL",
"pt-rBR",
"pt-rPT",
"ru",
"ru-rRU",
"ru-rUA",
"sa-rIN",
"sl-rSI",
"so-rSO",
"sr-rSP",
"ss-rZA",
"sv-rSE",
"sw-rKE",
"sw-rTZ",
"ta",
"ta-rIN",
"th",
"th-rTH",
"tr",
"tr-rTR",
"uk",
"uk-rUA",
"ur-rIN",
"uz-rUZ",
"vi-rVN",
"zh",
"zh-rCN",
"zh-rHK",
"zh-rSG",
"zh-rTW",
)
}
// Opt-in fast-build flags. Default behavior is unchanged.
//
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
// APK per (flavor, buildType) instead of 5.
// Cuts ~600 MB of intermediates and several
// minutes off CI.
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
// extra universal APK output. (No effect
// when disableAbiSplits is also set, since
// there are no splits to add to.)
// -Pamethyst.skipMapping=true disable R8 minification on release and
// benchmark. APK is larger, but builds are
// much faster and outputs/mapping/ (~260MB)
// is not produced. Local-dev and PR-CI use
// only — release pipelines must not set it.
val disableAbiSplits =
providers
.gradleProperty("disableAbiSplits")
.map { it.toBoolean() }
.getOrElse(false)
val disableUniversalApk =
providers
.gradleProperty("disableUniversalApk")
.map { it.toBoolean() }
.getOrElse(false)
val skipMapping =
providers
.gradleProperty("amethyst.skipMapping")
.map { it.toBoolean() }
.getOrElse(false)
buildTypes {
getByName("release") {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
isMinifyEnabled = !skipMapping
}
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
resValue("string", "app_name", "@string/app_name_debug")
}
create("benchmark") {
initWith(getByName("release"))
applicationIdSuffix = ".benchmark"
versionNameSuffix = "-BENCHMARK"
resValue("string", "app_name", "@string/app_name_benchmark")
isProfileable = true
signingConfig = signingConfigs.getByName("debug")
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
pickFirsts.add("builddef.lst")
pickFirsts.add("META-INF/LICENSE.md")
pickFirsts.add("META-INF/LICENSE-notice.md")
}
}
flavorDimensions += "channel"
productFlavors {
create("play") {
isDefault = true
dimension = "channel"
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "true")
}
create("fdroid") {
dimension = "channel"
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "false")
}
}
splits {
abi {
isEnable = !disableAbiSplits
reset()
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
isUniversalApk = !disableUniversalApk
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
}
}
lint {
disable += "MissingTranslation"
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
val tink = "com.google.crypto.tink:tink-android:1.17.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
substitute(module("com.google.crypto.tink:tink")).using(module(tink))
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
// Needs this to open gallery / image upload
implementation(libs.androidx.fragment.ktx)
// Navigation
implementation(libs.androidx.navigation.compose)
// Material 3 Design
implementation(libs.androidx.material3)
// Adaptive Layout / Two Pane
implementation(libs.androidx.material3.windowSize)
implementation(libs.accompanist.adaptive)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Zoomable images
implementation(libs.zoomable)
// Biometrics
implementation(libs.androidx.biometric.ktx)
// Background Work
implementation(libs.androidx.work.runtime.ktx)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// Encrypted Key Storage
implementation(libs.androidx.security.crypto.ktx)
implementation(libs.androidx.datastore.preferences)
// view videos
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui.compose.material3)
implementation(libs.androidx.media3.session)
// important for proxy / tor
implementation(libs.androidx.media3.datasource.okhttp)
// Load images from the web.
implementation(libs.coil.compose)
// view gifs
implementation(libs.coil.gif)
// view svgs
implementation(libs.coil.svg)
// enables network for coil
implementation(libs.coil.okhttp)
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation(libs.accompanist.permissions)
// For QR generation
implementation(libs.zxing)
implementation(libs.zxing.embedded)
// Markdown
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
// implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
implementation(libs.markdown.commonmark)
// Language picker and Theme chooser
implementation(libs.androidx.appcompat)
// Dynamically adjust between phone and tablet UI
implementation(libs.androidx.window.core.android)
// Local model for language identification
"playImplementation"(libs.google.mlkit.language.id)
// Google services model the translate text
"playImplementation"(libs.google.mlkit.translate)
// On-device AI writing assistance (Gemini Nano via AICore)
"playImplementation"(libs.google.mlkit.genai.proofreading)
"playImplementation"(libs.google.mlkit.genai.prompt)
"playImplementation"(libs.google.mlkit.genai.rewriting)
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
// with image-labeling as a keyword-join fallback for devices without AICore.
"playImplementation"(libs.google.mlkit.genai.image.description)
// PushNotifications
"playImplementation"(platform(libs.firebase.bom))
"playImplementation"(libs.firebase.messaging)
// PushNotifications(FDroid)
"fdroidImplementation"(libs.unifiedpush)
// Google Cast SDK — Chromecast support. Play flavor only because the
// framework hard-depends on Google Play services, which is unavailable
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
"playImplementation"(libs.play.services.cast.framework)
// Charts
implementation(libs.vico.charts.compose)
implementation(libs.vico.charts.m3)
// Waveform visualizer
implementation(libs.audiowaveform)
// Video compression lib
implementation(libs.abedElazizShe.video.compressor.fork)
// Image compression lib
implementation(libs.zelory.image.compressor)
// EXIF metadata stripping
implementation(libs.androidx.exifinterface)
// Voice anonymization DSP
implementation(libs.tarsosdsp)
// WebRTC for voice/video calls
implementation(libs.stream.webrtc.android)
// Cbor for cashuB format
implementation(libs.kotlinx.serialization.cbor)
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.mockk.android)
debugImplementation(platform(libs.androidx.compose.bom))
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
}
+120 -4
View File
@@ -19,10 +19,15 @@ rest of the system.
there. `NostrSignerRemote` (NIP-46) is deferred.
- **Chain backend.** User-configured Esplora-compatible API
(mempool.space, blockstream.info, self-hosted). The configured server sees
the user's UTXO queries — accepted tradeoff for v1. Header-only SPV mode is
a future-phase add. The explorer endpoint is shared with OpenTimestamps via
`BitcoinExplorerEndpoint`: same user-configured server, same Tor-aware
default selection.
the user's UTXO queries — accepted tradeoff for v1. **Spec PR
nostr-protocol/nips#2332 adds optional `["block", …]` and
`["proof", …]` tags on `kind:8333` that let a verifier with only the
Bitcoin header chain check a zap end-to-end without trusting the
explorer. Amethyst already parses both tags (see `BlockTag.kt` /
`ProofTag.kt`) but no production code path produces or consumes them
yet — see the "Inline SPV proofs" section below.** The explorer
endpoint is shared with OpenTimestamps via `BitcoinExplorerEndpoint`:
same user-configured server, same Tor-aware default selection.
- **Scope.** Full send + receive + display loop on Android. Desktop is out of
scope for v1.
@@ -232,6 +237,117 @@ Lightning fast path. Two minimal hooks:
2. **NIP-46 `sign_psbt`.** `NostrSignerRemote.signPsbt` still throws
`UnsupportedMethodException` — the bunker-side command is not standardized
yet.
3. **Send-side `block` + `proof` emit.** See "Inline SPV proofs" below.
4. **Receive-side SPV verification.** See "Inline SPV proofs" below.
## Inline SPV proofs (`block` + `proof` tags) — pending
### Spec status (2026-05-19)
`nostr-protocol/nips#2332` adds two new optional tags on `kind:8333`:
```
["block", "<block-hash-hex>", "<height>"]
["proof", "<raw-tx-hex>", "<merkle-proof-hex>"]
```
Together they let a verifier with only the Bitcoin header chain check a
zap end-to-end without trusting the chain backend. The recipient hashes
`raw-tx-hex` to recover the txid, walks the merkle proof from that leaf
up to the header's `merkleRoot`, parses outputs from `raw-tx-hex`, and
sums those that pay the recipient's derived Taproot script.
**One spec ambiguity outstanding** (raised back to the PR author):
direction-at-each-step encoding for `<merkle-proof-hex>` — Bitcoin's
merkle tree is tx-order-sensitive, so concatenated siblings alone don't
specify left vs right. Suggested resolution: `1-byte direction || 32-byte
sibling` per step (33 B stride). This blocks Phase G.2 (walker
implementation), nothing else.
### Current state in Amethyst
| Layer | Status | Location |
|---|---|---|
| `BlockTag(blockHashHex, height: Long)` | ✅ shipped | `quartz/.../nipBCOnchainZaps/zap/tags/BlockTag.kt` |
| `ProofTag(rawTxHex, merkleProofHex)` | ✅ shipped | `…/zap/tags/ProofTag.kt` |
| `OnchainZapEvent.block()` / `.proof()` accessors | ✅ shipped | `…/zap/OnchainZapEvent.kt:89-93` |
| TagArray + TagArrayBuilder helpers | ✅ shipped | `…/zap/TagArrayExt.kt`, `…/zap/TagArrayBuilderExt.kt` |
| Tx parser + witness-stripped txid (reusable for the walker) | ✅ shipped | `…/psbt/BitcoinTransaction.kt` |
| Recipient scriptPubKey derivation | ✅ shipped | `taproot/TaprootAddress.scriptPubKeyHexForRecipient` |
| `OnchainZapSender` populates `block` / `proof` after broadcast | ❌ gap | `commons/.../onchain/OnchainZapSender.kt:209-214` (calls `build()` / `buildProfileZap()` with no SPV tags) |
| `OnchainBackend.getMerkleProof(txid)` to source the proof on send | ❌ gap | `chain/OnchainBackend.kt` |
| Merkle-proof parser + walker | ❌ gap | new `verify/MerkleProofVerifier.kt` |
| `OnchainZapVerifier` prefers inline proof when present | ❌ gap | `verify/OnchainZapVerifier.kt` |
| Validated `header.merkleRoot` lookup by block hash | ❌ gap | depends on the headers-explorer plan (S1) at `quartz/plans/2026-05-08-local-headers-explorer.md` |
### Send-side: Design B (two-publish)
`OnchainZapSender` publishes the `kind:8333` receipt **immediately** after
`backend.broadcast(rawTxHex)` returns, when confirmations = 0. The chosen
design keeps that instant UX and adds a second publish after the tx
confirms:
1. Today's path stays. Publish `kind:8333` right after broadcast, with
`i`/`p`/`amount`/`alt` only — no `block`, no `proof`.
2. A new post-confirmation worker watches the tx via the backend. When it
sees ≥1 confirmation:
- Call `backend.getMerkleProof(txid)` → `(block_hash, height, siblings, pos)`.
- Encode `siblings + pos` into the spec's `<merkle-proof-hex>` wire format
(depends on the resolved spec encoding).
- Build a second `kind:8333` referencing the same `i` tag, with `block`
and `proof` filled in. Publish.
3. Verifiers MUST dedupe by `(txid, target)` and prefer the variant
carrying a valid SPV proof. (This dedupe rule is also in the suggested
spec amendment.)
Two relay broadcasts per zap, ~2 KB extra each. The post-confirmation
worker is per-account, persists across app restarts, and exits cleanly
once each pending tx is either confirmed (publish second receipt) or
abandoned (after some timeout — TBD).
### Receive-side
`OnchainZapVerifier.verify(event)` gets a new fast path:
1. If `event.block() != null && event.proof() != null`:
- Look up header by hash via `LocalHeadersBitcoinExplorer.byHash()` (S1).
If unavailable yet, fall through to the existing `backend.getTx()` path.
- `BitcoinTransaction.parse(rawTxHex)` → tx; recompute
`dsha256(stripWitness(tx))` → expected_txid; check it matches the
`i` tag's txid.
- `MerkleProofVerifier.verify(expected_txid, proofBytes, header.merkleRoot)` → boolean.
- Parse outputs from the same parsed tx; sum outputs paying the
recipient's `scriptPubKeyHexForRecipient`, skipping outputs paying
the sender's own derived script (change).
- Confirmations = `tip.height − block.height + 1`.
- Return `Confirmed`.
2. If the inline path fails verification (bad proof, missing header,
tx-mismatch), the event is treated as if `block` / `proof` were
absent — fall back to the existing Esplora path. **Never** treat a
failed SPV proof as a hard-reject of the whole event; that would let
a buggy producer poison legitimate zaps.
3. If neither tag is present (old-shape events), the existing
`backend.getTx()` path runs unchanged.
The end state: a strict-mode user with HTTP fallback disabled can verify
any zap whose sender included `block` + `proof`, and only those.
### Phased delivery for the SPV work
| Phase | Deliverable | Depends on | Effort |
|---|---|---|---|
| **G.1** | `MerkleProofVerifier` in `nipBCOnchainZaps/verify/`; test vectors per the resolved spec encoding | spec encoding locked | ½ d |
| **G.2** | `OnchainZapVerifier` consumes inline proof, falls back on failure; needs `LocalHeadersBitcoinExplorer.byHash()` from S1 | S1 Phase 2 (storage) | ½ d |
| **G.3** | `OnchainBackend.getMerkleProof(txid)` interface + `EsploraBackend` impl (`/tx/{txid}/merkle-proof`) | — | ½ d |
| **G.4** | Post-confirmation worker + second-publish flow in `OnchainZapSender` | G.3 | 1 d |
| **G.5** | Dedupe by `(txid, target)` preferring SPV-attached variant; `Note.zapsAmount` only counts the winner | — | ½ d |
| **G.6** | End-to-end tests: real mainnet block + proof + zap; negative cases (wrong root, truncated, wrong direction) | G.1 | 1 d |
| **G.7** | (Optional) Settings toggle "Verify zaps trustlessly when possible" — off by default until S1 ships | S1 Phase 7 | ½ d |
| **Total** | | | ~3–4 d (after S1 ships and spec encoding lands) |
S1 (the headers explorer at `quartz/plans/2026-05-08-local-headers-explorer.md`)
is the long pole. The SPV work itself is small — every primitive needed
already exists in the codebase, the only new thing is the merkle walker.
## Risks / open questions
@@ -1,225 +0,0 @@
/*
* 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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class UrlUserTagTransformationTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
@Test
fun testKeepTransformedIndexFullyInsideTransformedText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(original),
Color.Red,
)
val expected = "@Vitor Pamplona"
assertEquals(expected, transformedText.text.text)
// The mention is treated as an atomic wedge: any cursor strictly inside the
// underlying npub snaps to the trailing edge of the displayed "@Vitor Pamplona"
// (and vice versa). This prevents an IME from placing the cursor in the middle
// of the bech32 and corrupting it on backspace.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
for (i in 1..63) {
assertEquals("originalToTransformed($i)", 15, transformedText.offsetMapping.originalToTransformed(i))
}
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
for (i in 1..14) {
assertEquals("transformedToOriginal($i)", 64, transformedText.offsetMapping.transformedToOriginal(i))
}
assertEquals(64, transformedText.offsetMapping.transformedToOriginal(15))
}
@Test
fun transformationText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
// Outside the wedge: identity mapping.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ (boundary)
// Strictly inside the underlying npub: snaps to the end of "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
// End-of-wedge boundary maps to end of displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
// Outside the wedge in displayed: identity.
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) // Before @ (boundary)
// Strictly inside displayed "@Vitor Pamplona": snaps to end of underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22))
// End-of-wedge boundary maps to end of underlying mention; past it shifts by deltas.
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
}
@Test
fun transformationTextTwoKeys() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(
"New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
// Strictly inside the first underlying npub [8, 72): snap to end of first
// displayed "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
// Boundary at end of first wedge: end of first displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ (boundary, second wedge)
// Strictly inside the second underlying npub [77, 141): snap to end of second
// displayed "@Vitor Pamplona" (offset 43).
assertEquals(43, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(43, transformedText.offsetMapping.originalToTransformed(140))
// Strictly inside first displayed "@Vitor Pamplona" [8, 23): snap to end of
// first underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) // Before a (display)
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ (boundary, second wedge)
// Strictly inside second displayed "@Vitor Pamplona" [28, 43): snap to end of
// second underlying npub (offset 141).
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(29))
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(42))
}
}
@@ -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() {
}
@@ -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,
) {
}
@@ -26,6 +26,7 @@ import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.services.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -360,6 +361,12 @@ class AppModules(
client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL),
),
)
// NIP-57 Appendix F: validates incoming zap receipts against the
// recipient's LNURL provider's advertised `nostrPubkey`. Reuses the
// money-tier http client so Tor preferences and proxy settings apply.
cache.lnurlEndpointResolver =
OkHttpLnurlEndpointResolver(roleBasedHttpClientBuilder::okHttpClientForMoney)
}
// Provides a relay pool
@@ -641,6 +648,12 @@ class AppModules(
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
// periodic minimum and the lead-time window.
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
.schedule(appContext)
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
@@ -104,6 +104,7 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_PUBLIC_CHATS_FOLLOW_LIST = "defaultPublicChatsFollowList"
@@ -322,8 +323,16 @@ object LocalPreferences {
}
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
setCurrentAccount(accountSettings)
// Save the per-npub file before emitting onto the savedAccounts flow.
// Otherwise a collector (e.g. AlwaysOnNotificationServiceManager) can race in
// and call loadAccountConfigFromEncryptedStorage(npub) before NOSTR_PUBKEY is
// written, get null back, and poison `cachedAccounts[npub] = null` for the
// rest of the session — making every later switch to this account land on
// LoggedOff instead of LoggedIn.
saveToEncryptedStorage(accountSettings)
val npub = accountSettings.keyPair.pubKey.toNpub()
mutex.withLock { cachedAccounts.put(npub, accountSettings) }
setCurrentAccount(accountSettings)
}
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
@@ -361,6 +370,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value))
@@ -487,19 +497,20 @@ object LocalPreferences {
suspend fun loadAccountConfigFromEncryptedStorage(npub: String): AccountSettings? {
// if already loaded, return right away
if (cachedAccounts.containsKey(npub)) {
return cachedAccounts[npub]
}
cachedAccounts[npub]?.let { return it }
return withContext(Dispatchers.IO) {
mutex.withLock {
if (cachedAccounts.containsKey(npub)) {
return@withContext cachedAccounts.get(npub)
}
cachedAccounts[npub]?.let { return@withContext it }
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
cachedAccounts.put(npub, accountSettings)
// Only cache successful loads. Caching null would leave the account
// permanently unreachable for the rest of the session if a reader
// raced in before the per-npub file finished being written.
if (accountSettings != null) {
cachedAccounts.put(npub, accountSettings)
}
return@withContext accountSettings
}
@@ -641,6 +652,7 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
defaultPublicChatsFollowList = MutableStateFlow(followListPrefs.publicChats),
@@ -712,6 +724,7 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
val publicChats: TopFilter,
@@ -733,6 +746,7 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global),
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
@@ -404,7 +405,7 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, trustedRelayList, broadcastRelayList, scope)
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
// Follows Relays
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
@@ -489,6 +490,9 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
val liveProductsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultProductsFollowList)
val liveProductsFollowListsPerRelay = OutboxLoaderState(liveProductsFollowLists, cache, scope).flow
@@ -600,12 +604,14 @@ class Account(
suspend fun updateZapAmounts(
amountSet: List<Long>,
onchainAmountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
nip47Update: Nip47WalletConnect.Nip47URINorm?,
) {
var changed = false
if (settings.changeZapAmounts(amountSet)) changed = true
if (settings.changeOnchainZapAmounts(onchainAmountSet)) changed = true
if (settings.changeDefaultZapType(selectedZapType)) changed = true
if (settings.changeZapPaymentRequest(nip47Update)) changed = true
@@ -696,6 +702,8 @@ class Account(
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
@@ -704,6 +712,8 @@ class Account(
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
@@ -725,11 +735,24 @@ class Account(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
) {
): HexKey {
val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
@@ -743,6 +766,8 @@ class Account(
user: User,
message: String = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
@@ -751,6 +776,8 @@ class Account(
signer = signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
cache.justConsumeMyOwnEvent(zapRequest)
@@ -788,6 +815,34 @@ class Account(
) { template -> signAndComputeBroadcast(template) }
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
"Bitcoin chain backend is not configured",
)
return OnchainZapSender.sendSplit(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> signAndComputeBroadcast(template) }
}
suspend fun report(
note: Note,
type: ReportType,
@@ -2124,6 +2179,28 @@ class Account(
// --- Marmot Group Messaging ---
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
* canonical set; fall back to the account's outbox relays if the group
* has none (e.g. a group joined before MIP-01 metadata existed).
*
* Lives on Account (not AccountViewModel) so that headless callers —
* notifications' BroadcastReceiver, background workers — can resolve
* relays without spinning up a ViewModel.
*/
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
marmotManager
?.groupMetadata(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet()
return if (!groupRelays.isNullOrEmpty()) groupRelays else outboxRelays.flow.value
}
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
@@ -163,6 +163,7 @@ class AccountSettings(
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPublicChatsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -266,6 +267,15 @@ class AccountSettings(
return false
}
fun changeOnchainZapAmounts(newAmounts: List<Long>): Boolean {
if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) {
syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeReactionTypes(newTypes: List<String>): Boolean {
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
@@ -529,6 +539,17 @@ class AccountSettings(
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
fun changeDefaultCalendarsFollowList(name: TopFilter) {
if (defaultCalendarsFollowList.value != name) {
defaultCalendarsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultProductsFollowList(name: FeedDefinition) {
changeDefaultProductsFollowList(name.code)
}
@@ -35,11 +35,12 @@ class AccountSyncedSettings(
val reactions =
AccountReactionPreferences(
MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()),
MutableStateFlow(internalSettings.reactions.reactionRowItems.toImmutableList()),
MutableStateFlow(mergeWithDefaultReactionRowItems(internalSettings.reactions.reactionRowItems).toImmutableList()),
)
val zaps =
AccountZapPreferences(
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
MutableStateFlow(internalSettings.zaps.onchainZapAmountChoices.toImmutableList()),
MutableStateFlow(internalSettings.zaps.defaultZapType),
)
val languages =
@@ -69,6 +70,7 @@ class AccountSyncedSettings(
zaps =
AccountZapPreferencesInternal(
zaps.zapAmountChoices.value,
zaps.onchainZapAmountChoices.value,
zaps.defaultZapType.value,
),
languages =
@@ -96,7 +98,8 @@ class AccountSyncedSettings(
reactions.reactionChoices.tryEmit(newReactionChoices)
}
val newReactionRowItems = syncedSettingsInternal.reactions.reactionRowItems.toImmutableList()
val newReactionRowItems =
mergeWithDefaultReactionRowItems(syncedSettingsInternal.reactions.reactionRowItems).toImmutableList()
if (!equalImmutableLists(reactions.reactionRowItems.value, newReactionRowItems)) {
reactions.reactionRowItems.tryEmit(newReactionRowItems)
}
@@ -106,6 +109,11 @@ class AccountSyncedSettings(
zaps.zapAmountChoices.tryEmit(newZapChoices)
}
val newOnchainZapChoices = syncedSettingsInternal.zaps.onchainZapAmountChoices.toImmutableList()
if (!equalImmutableLists(zaps.onchainZapAmountChoices.value, newOnchainZapChoices)) {
zaps.onchainZapAmountChoices.tryEmit(newOnchainZapChoices)
}
if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) {
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
}
@@ -174,6 +182,7 @@ class AccountVideoPlayerPreferences(
@Stable
class AccountZapPreferences(
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
var onchainZapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
)
@@ -37,7 +37,8 @@ val DefaultReactions =
"\uD83D\uDE31",
)
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
val DefaultZapAmounts = listOf(21L, 50L, 100L)
val DefaultOnchainZapAmounts = listOf(5_000L)
val DefaultReportWarningThreshold = 5
@Serializable
@@ -67,6 +68,16 @@ val DefaultReactionRowItems =
ReactionRowItem(ReactionRowAction.Share, showCounter = false),
)
// Existing accounts have a reaction-row list serialized before some actions
// existed (e.g. Pay was added later). Append any default items the saved list
// is missing so new actions surface without forcing a settings reset — the
// user's existing order/toggles for actions they already have are preserved.
fun mergeWithDefaultReactionRowItems(saved: List<ReactionRowItem>): List<ReactionRowItem> {
val knownActions = saved.mapTo(mutableSetOf()) { it.action }
val missing = DefaultReactionRowItems.filter { it.action !in knownActions }
return if (missing.isEmpty()) saved else saved + missing
}
@Serializable
enum class VideoPlayerAction {
Fullscreen,
@@ -143,6 +154,7 @@ class AccountReactionPreferencesInternal(
@Serializable
class AccountZapPreferencesInternal(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
var onchainZapAmountChoices: List<Long> = DefaultOnchainZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
@@ -26,6 +26,7 @@ import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
@@ -41,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
@@ -189,6 +191,10 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnZapReceiptValidator
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointResolver
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
@@ -246,8 +252,6 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
@@ -304,6 +308,25 @@ object LocalCache : ILocalCache, ICacheProvider {
@Volatile
var onchainBackend: OnchainBackend? = null
/**
* NIP-BC on-chain zap verification coordinator. Owns the chain-tip poller flow,
* the in-flight de-duplication of verifier calls across consume/reverify paths,
* and the parallelism cap. `consume(OnchainZapEvent)` delegates the async
* verification side here; the gallery's reverification driver also calls in here
* directly.
*/
val onchainZapResolver = OnchainZapResolver(this)
/**
* Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to
* validate NIP-57 Appendix F. `null` skips the receipt-signer check (the
* receipt is still accepted on signature verification alone — matches legacy
* behavior); set this in app init so receipts can be verified against the
* recipient's provider's advertised `nostrPubkey`.
*/
@Volatile
var lnurlEndpointResolver: LnurlEndpointResolver? = null
val relayHints = HintIndexer()
val deletionIndex = DeletionIndex()
@@ -1709,37 +1732,123 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
if (wasVerified || justVerify(event)) {
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (existingZapRequest == null || existingZapRequest.event == null) {
// tries to add it
event.zapRequest?.let {
checkDeletionAndConsume(it, relay, false)
}
if (!(wasVerified || justVerify(event))) return false
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (existingZapRequest == null || existingZapRequest.event == null) {
// tries to add it
event.zapRequest?.let {
checkDeletionAndConsume(it, relay, false)
}
}
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" }
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" }
return false
}
// NIP-57 Appendix F validation. Resolve the recipient's lnurl from their
// profile metadata, look up the LNURL provider's `nostrPubkey`, and check
// the receipt against it (signer + invoice amount + lnurl tag).
//
// Synchronous path: cache hit, or no resolver wired (fallback to legacy
// signature-only behavior). Failed MUST-checks drop the receipt entirely.
//
// Async path: cache miss + resolver available. Load the event so feeds
// and live channels see it, but defer the `addZap` credit until the
// resolver returns. On async MUST-fail we never credit; the receipt
// stays in cache as a visible artifact but contributes 0 to zap totals.
val recipientLnurl = recipientLnurl(event)
val recipientLnurlpUrl = recipientLnurl?.let { LnurlForm.toUrl(it) }
val cachedInfo = recipientLnurlpUrl?.let { LnurlEndpointCache.get(it) }
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
if (cachedInfo != null) {
val result =
LnZapReceiptValidator.validate(
receipt = event,
expectedNostrPubkey = cachedInfo.nostrPubkey,
expectedLnurl = recipientLnurl,
)
if (result is LnZapReceiptValidator.Result.Invalid &&
result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL
) {
Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}")
return false
}
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
if (result is LnZapReceiptValidator.Result.Invalid) {
Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)")
}
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(zapRequest, note) }
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
return true
}
return false
val resolver = lnurlEndpointResolver
if (resolver == null || recipientLnurlpUrl == null) {
// No resolver configured, or recipient has no lud16/lud06 on file.
// Legacy behavior: accept the receipt on signature verification alone.
note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(zapRequest, note) }
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
return true
}
// Async validation. Make the event visible immediately, but only credit
// it to repliesTo after the resolver confirms the LNURL provider's pubkey.
note.loadEvent(event, author, repliesTo)
attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note)
Amethyst.instance.applicationIOScope.launch {
try {
val info = resolver.resolve(recipientLnurlpUrl)
if (info == null) {
Log.w("ZP", "could not fetch lnurlp for ${event.id}; not crediting")
return@launch
}
val result =
LnZapReceiptValidator.validate(
receipt = event,
expectedNostrPubkey = info.nostrPubkey,
expectedLnurl = recipientLnurl,
)
if (result is LnZapReceiptValidator.Result.Invalid &&
result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL
) {
Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}")
return@launch
}
if (result is LnZapReceiptValidator.Result.Invalid) {
Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)")
}
repliesTo.forEach { it.addZap(zapRequest, note) }
} catch (t: Throwable) {
Log.w("ZP", "validation failed for ${event.id}", t)
}
}
return true
}
/**
* Look up the recipient's lnurl from their kind:0 metadata. The recipient is
* the receipt's first `p` tag (which the LNURL provider sets to the original
* zap target). Returns null if we have no metadata, or the user has neither
* lud16 nor lud06.
*/
private fun recipientLnurl(event: LnZapEvent): String? {
val recipientPubkey = event.zappedAuthor().firstOrNull() ?: return null
val user = getUserIfExists(recipientPubkey) ?: return null
return user.lnAddress()?.takeIf { it.isNotBlank() }
}
fun consume(
@@ -1748,52 +1857,57 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val alreadyLoaded = note.event != null
if (!(wasVerified || justVerify(event))) return false
// `relay == null` means this event was generated locally by the signed-in user
// and routed through `justConsumeMyOwnEvent` — see `Account.sendOnchainZap` →
// `LocalCache.justConsumeMyOwnEvent`. Only these get the optimistic gallery
// attachment; for incoming zaps from others, the sender-claimed `amount` tag
// is untrusted and could mislead the viewer until the chain verifier responds.
val isOwnEvent = relay == null
var repliesTo: List<Note>? = null
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
if (!alreadyLoaded) {
if (!(wasVerified || justVerify(event))) return false
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
refreshNewNoteObservers(note)
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
val recipient = event.recipient() ?: return false
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
// Verification needs a chain backend. Without one (e.g. before Account
// wires its EsploraBackend) the event is still cached so subscriptions
// and profile zap views see it, but it can't contribute to Note totals.
val backend = onchainBackend ?: return true
val verifier = OnchainZapVerifier(backend)
val author = getOrCreateUser(event.pubKey)
val resolvedRepliesTo = computeReplyTo(event)
repliesTo = resolvedRepliesTo
note.loadEvent(event, author, resolvedRepliesTo)
Amethyst.instance.applicationIOScope.launch {
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(result.txid, result.verifiedSats, confirmed = true)
}
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(result.txid, result.verifiedSats, confirmed = false)
}
}
is VerifiedOnchainZap.Rejected -> {
Log.d("OnchainZap") {
"rejected ${result.txid}: ${result.reason}"
}
if (isOwnEvent) {
// Optimistic attachment for the sender's own zap: surface it on the
// thread immediately, before the chain backend has indexed the tx.
// Crucial because `OnchainZapSender.send` consumes the kind:8333
// milliseconds after broadcasting the tx — the verifier would
// otherwise return TX_NOT_FOUND and the entry would never appear
// until a later re-verification pass.
val txid = event.txid()
if (txid != null) {
// Clamp claimedSats to a non-negative value. `amount` tag parses
// via toLongOrNull() with no sign check, so a malicious sender
// could otherwise put "-1" in the gallery as a negative-sats badge.
val claimedSats = (event.claimedAmountInSats() ?: 0L).coerceAtLeast(0L)
resolvedRepliesTo.forEach {
it.addOnchainZap(note, txid, claimedSats, verifiedSats = 0L, OnchainZapStatus.UNVERIFIED)
}
}
} catch (t: Throwable) {
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
refreshNewNoteObservers(note)
}
return true
// Async chain verification is delegated to OnchainZapResolver, which owns the
// in-flight gates (so two relay echoes don't double-fetch) and the chain-tip
// polling flow used by the gallery driver. Reusing the repliesTo already
// computed above avoids the second computeReplyTo pass on the new-event path.
onchainZapResolver.launchVerification(event, note, repliesTo ?: computeReplyTo(event))
return !alreadyLoaded
}
private fun attachZapToLiveActivityChannel(
@@ -1975,6 +2089,12 @@ object LocalCache : ILocalCache, ICacheProvider {
if (note.event != null) return false
if (wasVerified || justVerify(event)) {
val expectedServicePubkey =
event.walletServicePubKey() ?: run {
Log.w("LocalCache", "NWC request ${event.id} has no `p` tag; cannot register for response.")
return false
}
note.loadEvent(event, author, emptyList())
relay?.let {
@@ -1983,7 +2103,7 @@ object LocalCache : ILocalCache, ICacheProvider {
zappedNote?.addZapPayment(note, null)
paymentTracker.registerRequest(event.id, zappedNote, onResponse)
paymentTracker.registerRequest(event.id, expectedServicePubkey, zappedNote, onResponse)
refreshNewNoteObservers(note)
@@ -1999,7 +2119,30 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
val requestId = event.requestId()
val pending = paymentTracker.onResponseReceived(requestId) ?: return false
val pending =
when (val match = paymentTracker.onResponseReceived(requestId, event.pubKey)) {
is NwcPaymentTracker.MatchResult.Matched -> match.pending
is NwcPaymentTracker.MatchResult.WrongAuthor -> {
// Possible spoof: a kind-23195 event from someone other than
// the wallet service we sent the request to. The pending
// entry is left in place so the real response can still
// resolve it; we silently drop this one.
Log.w(
"LocalCache",
"Rejecting NWC response ${event.id}: expected author ${match.expected} but event was signed by ${match.actual}. " +
"This may be a spoofed reply — keeping the request pending for the legitimate wallet response.",
)
return false
}
NwcPaymentTracker.MatchResult.NoMatch -> {
Log.w(
"LocalCache",
"NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag.",
)
return false
}
}
val zappedNote = pending.zappedNote
val responseCallback = pending.onResponse
@@ -52,6 +52,7 @@ data class UiSettings(
val showProfileAppRecommendations: Boolean = true,
val showProfileZapReceivedFeed: Boolean = true,
val showProfileFollowersFeed: Boolean = true,
val dontShowOnchainPublicWarning: Boolean = false,
)
enum class ThemeType(
@@ -52,6 +52,7 @@ class UiSettingsFlow(
val showProfileAppRecommendations: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showProfileZapReceivedFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -78,6 +79,7 @@ class UiSettingsFlow(
showProfileAppRecommendations,
showProfileZapReceivedFeed,
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
)
// emits at every change in any of the propertyes.
@@ -108,6 +110,7 @@ class UiSettingsFlow(
flows[20] as Boolean,
flows[21] as Boolean,
flows[22] as Boolean,
flows[23] as Boolean,
)
}
@@ -136,6 +139,7 @@ class UiSettingsFlow(
showProfileAppRecommendations.value,
showProfileZapReceivedFeed.value,
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -233,6 +237,10 @@ class UiSettingsFlow(
showProfileFollowersFeed.tryEmit(torSettings.showProfileFollowersFeed)
any = true
}
if (dontShowOnchainPublicWarning.value != torSettings.dontShowOnchainPublicWarning) {
dontShowOnchainPublicWarning.tryEmit(torSettings.dontShowOnchainPublicWarning)
any = true
}
return any
}
@@ -249,6 +257,12 @@ class UiSettingsFlow(
}
}
fun dontShowOnchainPublicWarning() {
if (!dontShowOnchainPublicWarning.value) {
dontShowOnchainPublicWarning.tryEmit(true)
}
}
companion object {
fun build(uiSettings: UiSettings): UiSettingsFlow =
UiSettingsFlow(
@@ -275,6 +289,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileAppRecommendations),
MutableStateFlow(uiSettings.showProfileZapReceivedFeed),
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
)
}
}
@@ -45,7 +45,7 @@ class Nip11Retriever(
relay: NormalizedRelayUrl,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
) {
) = withContext(Dispatchers.IO) {
val url = relay.toHttp()
try {
val request: Request =
@@ -58,27 +58,25 @@ class Nip11Retriever(
val client = okHttpClient(relay)
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
} catch (e: Exception) {
@@ -144,15 +144,17 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
val assembler = nwcFilterAssembler()
assembler.subscribe(filter)
// Synchronous flush so the REQ frame is queued on the WebSocket before
// the EVENT is published. Without this, the bundler may delay REQ up
// to 500ms, and the wallet service's ephemeral kind 23195 reply can
// be missed.
assembler.subscribeAndFlush(filter)
scope.launch(Dispatchers.IO) {
delay(60000)
@@ -181,15 +183,15 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
val assembler = nwcFilterAssembler()
assembler.subscribe(filter)
// Synchronous flush so the REQ frame is queued before the EVENT.
// See sendNwcRequestToWallet above for the rationale.
assembler.subscribeAndFlush(filter)
scope.launch(Dispatchers.IO) {
delay(60000) // waits 1 minute to complete payment.
@@ -0,0 +1,295 @@
/*
* 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.model.nipBCOnchainZaps
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier
import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.ConcurrentHashMap
/**
* Coordinates NIP-BC on-chain zap verification on top of the chain backend.
*
* `LocalCache.consume(OnchainZapEvent)` owns the event dispatch and optimistic
* gallery attachment; this class owns the asynchronous chain-verification side:
*
* - Launching a verifier coroutine when a new event arrives (with per-event
* in-flight de-duplication so simultaneous relay echoes don't double-fetch).
* - Re-running verification for visible notes when the chain tip advances or
* when a screen first comes into view.
* - Owning the shared chain-tip poller flow that UI subscribes to.
*
* Stateless from the caller's perspective — the per-event resolution state lives
* on the source [Note] itself (`onchainZapResolved`), so it travels with the Note
* across cache eviction and doesn't accumulate here.
*/
class OnchainZapResolver(
private val cache: LocalCache,
) {
/**
* Caps the parallelism of [reverifyOnchainZapsForNote] so a thread with many
* pending entries doesn't blast public Esplora endpoints with a burst of
* simultaneous requests.
*/
private val reverifySemaphore = Semaphore(permits = 8)
/**
* In-flight set of event ids currently being verified. Prevents two `consume()`
* calls (or a `consume` plus a reverify) from issuing parallel Esplora fetches
* for the same event. `ConcurrentHashMap.newKeySet` gives lock-free atomic `add`
* returning `true` only for the inserting caller.
*/
private val verifyingEventIds: MutableSet<HexKey> = ConcurrentHashMap.newKeySet()
/**
* In-flight set of note id strings currently being reverified. Lets the on-chain
* zap gallery driver be called from many visible composables without the same
* note's reverify pass running concurrently. The per-event gate above is the
* backstop; this one short-circuits earlier and avoids creating async coroutines
* that would just no-op.
*/
private val reverifyingNoteIds: MutableSet<HexKey> = ConcurrentHashMap.newKeySet()
/**
* Shared poller for the current bitcoin chain tip height. Each gallery that
* holds non-CONFIRMED on-chain zaps subscribes to this flow and re-verifies its
* entries whenever the tip advances. Lazy + `WhileSubscribed` so the HTTP call
* only fires when at least one UI surface needs it.
*
* Lazy initialization is required because [Amethyst.instance] may not exist
* when [OnchainZapResolver] is first constructed. Falls back to a constant
* null-emitting [StateFlow] if the application scope isn't available yet
* (e.g. unit tests, ContentProvider invocations), so the lazy field doesn't
* permanently fail with `UninitializedPropertyAccessException`.
*/
val onchainTipHeightFlow: StateFlow<Long?> by lazy {
val scope =
runCatching { Amethyst.instance.applicationIOScope }.getOrNull()
?: return@lazy MutableStateFlow<Long?>(null).asStateFlow()
flow {
while (true) {
val tip =
cache.onchainBackend?.let { backend ->
// Explicit try/catch instead of `runCatching` because the latter
// would swallow CancellationException too — when the upstream
// scope cancels, we must let it propagate so the flow tears down
// promptly instead of looping through one more delay().
try {
backend.tipHeight()
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
null
}
}
emit(tip)
delay(TIP_POLL_INTERVAL_MS)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
initialValue = null,
)
}
/**
* Launches an asynchronous chain verification for [event] unless one is already
* in flight or [source] has already reached a terminal verdict. Returns
* immediately. Apply-to-Note updates happen on the application IO scope.
*
* [repliesTo] is the pre-computed list of notes the event references so consume()
* doesn't pay the cost of resolving it twice.
*/
fun launchVerification(
event: OnchainZapEvent,
source: Note,
repliesTo: List<Note>,
) {
val backend = cache.onchainBackend ?: return
if (source.onchainZapResolved) return
if (!verifyingEventIds.add(event.id)) return
val verifier = OnchainZapVerifier(backend)
Amethyst.instance.applicationIOScope.launch {
try {
verifyAndUpgradeOnchainZap(event, source, repliesTo, verifier)
} finally {
verifyingEventIds.remove(event.id)
}
}
}
/**
* Re-run on-chain zap verification for every non-CONFIRMED entry attached to
* [note]. Safe to call from a screen-visibility hook or a chain-tip change
* observer; each verifier call is bounded by the chain backend's cache TTLs.
*
* - Per-note in-flight gate ([reverifyingNoteIds]) — if another caller is
* already reverifying this note (e.g. multiple visible galleries fired in
* the same tip tick) we skip the dup.
* - Per-event in-flight gate ([verifyingEventIds]) — coordinates with
* [launchVerification] so the same event isn't verified twice concurrently.
* - [supervisorScope] — a single verifier failure won't cancel sibling
* verifiers for other entries on the same note.
* - Bounded parallelism via [reverifySemaphore] so a thread with many pending
* entries doesn't blast Esplora.
*/
suspend fun reverifyOnchainZapsForNote(note: Note) {
val backend = cache.onchainBackend ?: return
if (!reverifyingNoteIds.add(note.idHex)) return
try {
val pendingEntries =
note.onchainZaps.values.filter { it.status != OnchainZapStatus.CONFIRMED }
if (pendingEntries.isEmpty()) return
val verifier = OnchainZapVerifier(backend)
supervisorScope {
pendingEntries
.mapNotNull { entry ->
val sourceEvent = entry.source.event as? OnchainZapEvent ?: return@mapNotNull null
val source = entry.source
if (source.onchainZapResolved) return@mapNotNull null
if (!verifyingEventIds.add(sourceEvent.id)) return@mapNotNull null
async {
try {
reverifySemaphore.withPermit {
val repliesTo = cache.computeReplyTo(sourceEvent)
verifyAndUpgradeOnchainZap(sourceEvent, source, repliesTo, verifier)
}
} finally {
verifyingEventIds.remove(sourceEvent.id)
}
}
}.awaitAll()
}
} finally {
reverifyingNoteIds.remove(note.idHex)
}
}
/**
* Run the chain verifier for a single on-chain zap event and apply the result to
* every target note. Designed to be safe to call repeatedly — the [Note] entries
* upgrade monotonically (UNVERIFIED → PENDING → CONFIRMED) and won't move
* backwards, and source-scoped removal prevents one event's rejection from
* erasing another sender's legitimate entry that happens to share a txid.
*
* Callers must wrap the call site with the [verifyingEventIds] gate; this
* function does not itself protect against duplicate concurrent runs.
*/
private suspend fun verifyAndUpgradeOnchainZap(
event: OnchainZapEvent,
source: Note,
repliesTo: List<Note>,
verifier: OnchainZapVerifier,
) {
// Clamp claimedSats to non-negative — `amount` tag parses via toLongOrNull()
// with no sign check, so a malicious sender could otherwise put "-1" in the
// gallery as a negative-sats badge.
val claimedSats = (event.claimedAmountInSats() ?: 0L).coerceAtLeast(0L)
try {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(source, result.txid, claimedSats, result.verifiedSats, OnchainZapStatus.CONFIRMED)
}
// Terminal — the chain has confirmed. Future relay echoes of this
// event id skip the verifier entirely via the `onchainZapResolved`
// gate in `launchVerification`.
source.onchainZapResolved = true
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(source, result.txid, claimedSats, result.verifiedSats, OnchainZapStatus.PENDING)
}
// Not terminal — the tx is in the mempool. A future tip-poll or
// reverify call can upgrade it to CONFIRMED.
}
is VerifiedOnchainZap.Rejected -> {
if (result.reason == VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND) {
// Transient — the tx may not have propagated to the backend's
// indexer yet. Leave the entry as UNVERIFIED so a later
// reverifyOnchainZapsForNote() call (e.g. on chain tip change)
// can promote it.
Log.d("OnchainZap") { "tx not yet indexed for ${event.id} (${result.txid}); will retry" }
} else if (result.txid.isNotEmpty()) {
// Hard rejection — e.g. ZERO_VERIFIED_AMOUNT means this sender's
// tx did not pay the recipient. Drop only entries whose source
// matches THIS event AND that aren't CONFIRMED (the per-target
// CONFIRMED check inside `removeOnchainZapForSource` prevents a
// transient backend hiccup from wiping a previously-verified
// entry on a sibling target).
Log.d("OnchainZap") { "rejected ${result.txid}: ${result.reason}" }
repliesTo.forEach { it.removeOnchainZapForSource(result.txid, event.pubKey) }
// Terminal — don't re-verify on future relay echoes of this
// event id. (Different event ids with the same txid still go
// through their own verifier pass.)
source.onchainZapResolved = true
} else {
// MISSING_TXID etc. — log so we have observability when a
// malformed event reaches the backend. Mark terminal so we
// don't re-verify the same broken event on every echo.
Log.d("OnchainZap") { "rejected ${event.id}: ${result.reason} (no txid)" }
source.onchainZapResolved = true
}
}
}
} catch (t: Throwable) {
// Never swallow cancellation — it must propagate so screen-scoped callers
// (the gallery's reverification driver) can tear down cleanly.
if (t is CancellationException) throw t
Log.w("OnchainZap", "verification failed for ${event.id}", t)
}
}
companion object {
/**
* Polling interval for the shared on-chain chain-tip flow. Aligned with
* `CachingOnchainBackend.tipHeightTtlSeconds` (60s) — polling faster would
* just hit the cache and do no useful work.
*/
private const val TIP_POLL_INTERVAL_MS = 60_000L
}
}
@@ -116,6 +116,7 @@ class UiSharedPreferences(
val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations")
val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed")
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -150,6 +151,7 @@ class UiSharedPreferences(
showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true,
showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true,
showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true,
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -197,6 +199,7 @@ class UiSharedPreferences(
preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
@@ -43,6 +45,8 @@ class TrustedRelayListsState(
val localRelayList: LocalRelayListState,
val dmRelayList: DmRelayListState,
val searchRelayListState: SearchRelayListState,
val indexerRelayList: IndexerRelayListState,
val proxyRelayList: ProxyRelayListState,
val trustedRelayList: TrustedRelayListState,
val broadcastRelayList: BroadcastRelayListState,
val scope: CoroutineScope,
@@ -57,6 +61,8 @@ class TrustedRelayListsState(
localRelayList.flow,
dmRelayList.flow,
searchRelayListState.flowNoDefaults,
indexerRelayList.flowNoDefaults,
proxyRelayList.flow,
trustedRelayList.flow,
broadcastRelayList.flow,
),
@@ -70,6 +76,8 @@ class TrustedRelayListsState(
localRelayList.flow.value,
dmRelayList.flow.value,
searchRelayListState.flowNoDefaults.value,
indexerRelayList.flowNoDefaults.value,
proxyRelayList.flow.value,
trustedRelayList.flow.value,
broadcastRelayList.flow.value,
),
@@ -86,6 +94,8 @@ class TrustedRelayListsState(
localRelayList.flow.value,
dmRelayList.flow.value,
searchRelayListState.flowNoDefaults.value,
indexerRelayList.flowNoDefaults.value,
proxyRelayList.flow.value,
trustedRelayList.flow.value,
broadcastRelayList.flow.value,
),
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.collections.immutable.ImmutableList
@@ -178,7 +179,7 @@ class ZapPaymentHandler(
onProgress(0.02f)
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend)
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats)
if (splitZapRequests.isEmpty()) {
onProgress(0.00f)
@@ -237,8 +238,10 @@ class ZapPaymentHandler(
message: String,
zapType: LnZapEvent.ZapType,
zapsToSend: List<MyZapSplitSetup>,
): List<ZapRequestReady> =
mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
totalAmountMilliSats: Long,
): List<ZapRequestReady> {
val totalWeight = zapsToSend.sumOf { it.weight }
return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
// makes sure the author receives the zap event
val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
@@ -247,15 +250,31 @@ class ZapPaymentHandler(
val noteEvent = note.event
// Per NIP-57 §6: the zap request SHOULD carry `amount` (in msats) and
// `lnurl` so the recipient's LNURL provider — and clients reading the
// resulting receipt — can validate them under Appendix F.
val splitAmount = calculateZapValue(totalAmountMilliSats, next.weight, totalWeight)
val splitLnurl = LnurlForm.toUrl(next.lnAddress)?.let(LnurlForm::urlToBech32)
val zapRequest =
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
account.createZapRequestFor(noteEvent, pollOption, message, zapType, next.user, userRelayList + authorRelayList)
account.createZapRequestFor(
event = noteEvent,
pollOption = pollOption,
message = message,
zapType = zapType,
toUser = next.user,
additionalRelays = userRelayList + authorRelayList,
amountMillisats = splitAmount,
lnurl = splitLnurl,
)
} else {
null
}
ZapRequestReady(next, zapRequest)
}
}
suspend fun assembleAllInvoices(
requests: List<ZapRequestReady>,
@@ -0,0 +1,119 @@
/*
* 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.service.calendar
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
* to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier]
* so the two notification surfaces stay consistent.
*/
object CalendarReminderNotifier {
@Volatile
private var channel: NotificationChannel? = null
private const val REMINDER_NOT_ID_BASE = 0x80000
/**
* @param eventId the appointment's event id — used to derive a stable notification id so a
* second reminder for the same event collapses rather than stacking.
* @param title the appointment title (or a fallback string).
* @param body a short pre-formatted body, e.g. "Starts in 15 minutes".
* @param deepLink a `nostr:naddr…` URI for the calendar event. Tapping the notification
* hands this to MainActivity, which routes it via `uriToRoute` → the
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
* branches in RouteMaker resolve to Route.CalendarEventDetail).
*/
fun notifyReminder(
context: Context,
eventId: String,
title: String,
body: String,
deepLink: String,
) {
ensureChannel(context)
val notId = idFor(eventId)
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse(deepLink)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val tapPendingIntent =
PendingIntent.getActivity(
context,
notId,
tapIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val nm = NotificationManagerCompat.from(context)
// POST_NOTIFICATIONS is runtime-granted on Android 13+; bail when denied so the lint
// call below doesn't flag and we don't log a misleading no-op.
if (!nm.areNotificationsEnabled()) return
val notification =
NotificationCompat
.Builder(context, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(tapPendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setAutoCancel(true)
.setWhen(System.currentTimeMillis())
.build()
try {
nm.notify(notId, notification)
} catch (_: SecurityException) {
// Race: permission revoked between the check above and notify().
}
}
fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
stringRes(context, R.string.calendar_reminder_channel_id),
stringRes(context, R.string.calendar_reminder_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = stringRes(context, R.string.calendar_reminder_channel_description)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel!!)
}
// Distinct id per event so two reminders for the same event collapse onto one row while
// separate events render side by side.
private fun idFor(eventId: String): Int = REMINDER_NOT_ID_BASE xor eventId.hashCode()
}
@@ -0,0 +1,65 @@
/*
* 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.service.calendar
import android.content.Context
import android.content.SharedPreferences
/**
* Device-wide preferences for the calendar reminder worker.
*
* Stored at device scope (rather than per-account) because the worker that consults them runs
* globally — multiplexing per-account preferences would require account-context plumbing into
* WorkManager that the rest of the app doesn't have. A user who flips between two accounts on
* the same device shares the same lead-time and enabled-state. Per-account preferences could be
* a follow-up if anyone asks.
*/
class CalendarReminderPrefs(
context: Context,
) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
fun setEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
}
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
fun setLeadMinutes(minutes: Int) {
prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply()
}
companion object {
const val DEFAULT_LEAD_MINUTES = 15
const val DEFAULT_ENABLED = true
// Choices presented in the settings UI. Anchored to the worker cadence — lead times
// smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the
// UX shape supports without an extra "hours" picker.
val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60)
private const val PREF_NAME = "amethyst_calendar_reminder_prefs"
private const val KEY_ENABLED = "enabled"
private const val KEY_LEAD_MINUTES = "lead_minutes"
}
}
@@ -0,0 +1,83 @@
/*
* 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.service.calendar
import android.content.Context
import android.content.SharedPreferences
/**
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
* the worker that consults it runs in the app process and the dataset is tiny (≤ a few hundred
* IDs at most). Without persistence, every worker run after a restart would re-notify for the
* same upcoming event until it started, since LocalCache has no memory of past reminders.
*
* Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only
* presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just
* fired so the store doesn't grow unbounded over time.
*/
class CalendarReminderStore(
context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
/**
* Returns true when we've previously notified for this exact event-start pairing. If the
* author updates the appointment to a new start time, the stored value won't match and
* we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved
* meeting shouldn't be silently skipped.
*/
fun wasNotified(
eventId: String,
eventStartSeconds: Long,
): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds
fun markNotified(
eventId: String,
eventStartSeconds: Long,
) {
prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply()
}
/**
* Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called
* after each worker run so the store stays bounded — events that have long since ended
* can't fire a second reminder, so their entries are dead weight.
*/
fun forgetBefore(cutoffSeconds: Long) {
val editor = prefs.edit()
var changed = false
prefs.all.forEach { (key, value) ->
if (value is Long && value < cutoffSeconds) {
editor.remove(key)
changed = true
}
}
if (changed) editor.apply()
}
companion object {
private const val PREF_NAME = "amethyst_calendar_reminders"
private const val KEY_PREFIX = "notified:"
private fun keyFor(eventId: String) = KEY_PREFIX + eventId
}
}
@@ -0,0 +1,140 @@
/*
* 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.service.calendar
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import java.util.concurrent.TimeUnit
/**
* Periodic scan that posts "starting soon" notifications for appointments the user has RSVP'd
* to as ACCEPTED.
*
* The work is bounded — scans LocalCache (which is bounded by the relay subscription) and
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
*/
class CalendarReminderWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val prefs = CalendarReminderPrefs(applicationContext)
if (!prefs.isEnabled()) {
Log.d(TAG) { "Reminders disabled; skipping scan." }
return Result.success()
}
val now = TimeUtils.now()
val windowEnd = now + prefs.leadMinutes() * 60L
val store = CalendarReminderStore(applicationContext)
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
// present in cache — the alternative (looking only at the foreground account) would
// silently break notifications for account switching during the lead window.
val acceptedRsvps =
LocalCache.addressables
.filterIntoSet { _, note ->
val e = note.event
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
}.mapNotNull { it.event as? CalendarRSVPEvent }
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
acceptedRsvps.forEach { rsvp ->
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
val targetNote = LocalCache.addressables.get(targetAddress) ?: return@forEach
val view = targetNote.appointmentView() ?: return@forEach
val start = view.startSeconds ?: return@forEach
val eventId =
(targetNote.event as? CalendarTimeSlotEvent)?.id
?: (targetNote.event as? CalendarDateSlotEvent)?.id
?: return@forEach
if (start !in now..windowEnd) return@forEach
// Keyed on (eventId, start) so a moved appointment re-fires when the new start
// enters the lead window — the old notification stays valid in the system tray.
if (store.wasNotified(eventId, start)) return@forEach
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
val body =
pluralStringRes(
applicationContext,
R.plurals.calendar_reminder_body,
minutesAway,
minutesAway,
)
val deepLink =
"nostr:" +
com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
.create(targetAddress.kind, targetAddress.pubKeyHex, targetAddress.dTag, null)
CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body, deepLink)
store.markNotified(eventId, start)
Log.d(TAG) { "Notified $eventId (starts in ${minutesAway}m)" }
}
// Prune entries for events that ended more than a day ago — they can't fire again.
store.forgetBefore(now - PRUNE_AGE_SECONDS)
return Result.success()
}
companion object {
private const val TAG = "CalendarReminderWorker"
private const val WORK_NAME = "calendar_reminder_worker"
// Don't bother remembering "I notified for this" entries for events whose start was
// more than a day ago; they can't fire again so the entry is pure overhead.
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
fun schedule(context: Context) {
val request =
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
Log.d(TAG) { "schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP)" }
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
}
@@ -24,7 +24,10 @@ import android.os.Build
import com.vitorpamplona.amethyst.BuildConfig
class ReportAssembler {
fun buildReport(e: Throwable): String =
fun buildReport(
e: Throwable,
threadName: String = Thread.currentThread().name,
): String =
buildString {
append(e.javaClass.simpleName)
append(": ")
@@ -70,9 +73,14 @@ class ReportAssembler {
append("| User | ")
append(Build.USER)
appendLine(" |")
append("| Thread | ")
append(threadName)
appendLine(" |")
appendLine()
appendLine("```")
append("Thread: ")
appendLine(threadName)
appendLine(e.toString())
e.stackTrace.forEach {
append(" ")
@@ -35,8 +35,9 @@ class UnexpectedCrashSaver(
) {
if (e !is OutOfMemoryError) {
// OOM reports are junk
val threadName = t.name
scope.launch {
cache.writeReport(ReportAssembler().buildReport(e))
cache.writeReport(ReportAssembler().buildReport(e, threadName))
}
}
defaultUEH!!.uncaughtException(t, e)
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -210,6 +212,8 @@ class LightningAddressResolver {
): String {
val mapper = jacksonObjectMapper()
val lnurlpUrl = assembleUrl(lnAddress)
val lnAddressJson =
fetchLightningAddressJson(
lnAddress,
@@ -248,6 +252,17 @@ class LightningAddressResolver {
}
val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false
val nostrPubkey = lnurlp.get("nostrPubkey")?.asText()?.ifBlank { null }
// Prime the receipt-validation cache so incoming zap receipts can be
// checked against this provider's nostrPubkey (NIP-57 Appendix F)
// without re-fetching the lnurlp endpoint.
if (lnurlpUrl != null) {
LnurlEndpointCache.put(
lnurlpUrl,
LnurlEndpointInfo(nostrPubkey = nostrPubkey, allowsNostr = allowsNostr),
)
}
val invoice =
fetchLightningInvoice(
@@ -32,6 +32,14 @@ import com.vitorpamplona.quartz.utils.Log
object CachedReversedGeoLocations {
val locationNames = LruCache<String, String>(20)
// Geocoder.isPresent() reflects whether the system has a backing
// IGeocodeProvider registered — fixed at boot, safe to memoize. On AOSP /
// GrapheneOS without Google Play Services or microG configured no provider
// is registered and this returns false.
private val geocoderAvailable: Boolean by lazy { Geocoder.isPresent() }
fun isGeocoderAvailable(): Boolean = geocoderAvailable
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
fun geoLocate(
@@ -42,31 +50,24 @@ object CachedReversedGeoLocations {
) {
locationNames[geoHashStr]?.let {
onReady(it)
return
}
if (Geocoder.isPresent()) {
ReverseGeolocation.execute(location, context) { cityNames ->
if (cityNames != null) {
val cityName =
cityNames.firstNotNullOfOrNull {
val name = it.toCityCountry()
if (!name.isBlank()) {
name
} else {
null
}
}
if (cityName != null) {
locationNames.put(geoHashStr, cityName)
onReady(cityName)
}
} else {
// error
onReady(null)
if (!geocoderAvailable) {
Log.d("ReverseGeoLocation") { "Geocoder service not present on this device" }
onReady(null)
return
}
ReverseGeolocation.execute(location, context) { cityNames ->
val cityName =
cityNames?.firstNotNullOfOrNull {
it.toCityCountry().takeIf { name -> name.isNotBlank() }
}
if (cityName != null) {
locationNames.put(geoHashStr, cityName)
}
} else {
Log.d("ReverseGeoLocation", "Geocoder not present")
onReady(cityName)
}
}
}
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
@@ -56,6 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
@@ -147,15 +149,34 @@ class EventNotificationConsumer(
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
// Ask the event which of our signing accounts it's notifying.
// Each kind declares its own notification semantics in
// [Event.notifies] (lowercase `p` by default, NIP-22 also checks
// uppercase `P`, etc.), so we don't hard-code tag names here.
// Match the in-app Notifications feed exactly: the event must
// p-tag the account AND pass NotificationFeedFilter.tagsAnEventByUser
// — the latter is the per-kind "is this actually for me" rule
// (reply-to-me, citation of my post, fork of my content, community
// moderation, reaction targeting my note, etc.). WakeUpEvent is the
// one exception: its [Event.notifies] is `true` unconditionally so
// every signed-in account processes it (the dispatcher bypasses
// the same way).
//
// One LocalCache lookup per event regardless of account count —
// the note is the same for every saved account. Use the Event
// overload so AddressableEvent kinds resolve to their replaceable
// note (id-keyed version has empty replyTo after insertion).
val matchingNote: Note? =
if (event is WakeUpEvent) {
null
} else {
LocalCache.getNoteIfExists(event) ?: return@withWakeLock
}
LocalPreferences.allSavedAccounts().forEach { savedAccount ->
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach
if (!event.notifies(accountHex)) return@forEach
if (matchingNote != null) {
if (!event.isTaggedUser(accountHex)) return@forEach
if (!NotificationFeedFilter.tagsAnEventByUser(matchingNote, accountHex)) return@forEach
}
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
try {
@@ -204,6 +225,17 @@ class EventNotificationConsumer(
// to repeat either check.
if (event.pubKey == account.signer.pubKey) return
// Mirror NotificationFeedFilter: reactions and zaps target a note via
// `replyTo`, not via thread-root tags on the wrapper event, so checking
// the wrapper's own thread misses them. Resolve the target and drop if
// its thread is muted. (The feed extends this to reposts too, but
// RepostEvent / GenericRepostEvent aren't routed below — push doesn't
// notify on reposts at all today.)
if (event is ReactionEvent || event is LnZapEvent) {
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return
}
when (event) {
is PrivateDmEvent -> notify(event, account)
@@ -554,6 +586,9 @@ class EventNotificationConsumer(
accountNpub = accountNpub,
accountPictureUrl = account.userProfile().profilePicture(),
chatroomMembers = null,
marmotNostrGroupId = nostrGroupId,
marmotReplyToInnerEventId = innerEvent.id,
marmotReplyToInnerAuthor = innerEvent.pubKey,
)
}
@@ -24,10 +24,12 @@ import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
@@ -173,15 +175,37 @@ class NotificationDispatcher(
// matches the downstream per-channel policy. Calls
// use a stricter 20s check in notifyIncomingCall so
// they still pass through.
// - event.notifies(pubkey) for any of our accounts —
// each kind decides which tag(s) name its recipients
// (lowercase `p` by default, plus uppercase `P` for
// NIP-22 root authors, etc.).
val predicate = { event: Event ->
event.kind in NOTIFICATION_KINDS &&
event.createdAt >= dispatcherSince &&
event.createdAt >= TimeUtils.fifteenMinutesAgo() &&
pubkeys.any { event.notifies(it) }
// - WakeUpEvent bypasses recipient matching: its
// [Event.notifies] is `true` unconditionally, meaning
// "wake every signed-in account" (p-tags on a WakeUp
// name referenced-event authors, not recipients).
// - Everything else uses isTaggedUser + tagsAnEventByUser
// — same pair the in-app Notifications feed uses, so
// push and feed agree on what counts as a mention,
// reply, citation, fork, or community moderation.
// Note: NIP-22 CommentEvents where the user is only
// the root author (uppercase `P`) no longer match —
// intentional alignment with the feed, which gates
// on lowercase `p` only.
val predicate = predicate@{ event: Event ->
if (event.kind !in NOTIFICATION_KINDS) return@predicate false
if (event.createdAt < dispatcherSince) return@predicate false
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return@predicate false
if (event is WakeUpEvent) return@predicate true
// getNoteIfExists(event) — not (event.id) — so
// AddressableEvent kinds (LongTextNote, WikiNote,
// LiveChess*, VideoHorizontal/Vertical) resolve to
// their address-keyed replaceable note. The id-keyed
// version note has its replyTo moved away during
// insertion (LocalCache.consumeBaseReplaceable), so
// tagsAnEventByUser's replyTo check would otherwise
// always miss for replies into addressable posts.
val note = LocalCache.getNoteIfExists(event) ?: return@predicate false
pubkeys.any { pubkey ->
event.isTaggedUser(pubkey) &&
NotificationFeedFilter.tagsAnEventByUser(note, pubkey)
}
}
LocalCache
@@ -94,6 +94,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
sendPublicReply(accountNpub, targetEventId, replyText)
}
}
NotificationUtils.MARMOT_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val nostrGroupId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_GROUP_ID) ?: return
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
runOnRelay(notificationManager, notificationId) {
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
}
}
}
}
@@ -140,6 +159,34 @@ class NotificationReplyReceiver : BroadcastReceiver() {
account.sendNip17PrivateMessage(template)
}
private suspend fun sendMarmotReply(
accountNpub: String,
nostrGroupId: String,
replyToInnerEventId: String?,
replyToInnerAuthor: String?,
replyText: String,
) {
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
val manager = account.marmotManager ?: return
// Use id+author from the Intent so the reply is threaded even when
// LocalCache hasn't been rehydrated yet (cold-process broadcast
// receiver: Account.restoreAll runs async on init and may not have
// finished by the time we get here).
val bundle =
manager.buildTextMessage(
nostrGroupId = nostrGroupId,
text = replyText,
replyToEventId = replyToInnerEventId,
replyToAuthorPubKey = replyToInnerAuthor,
persistOwn = false,
)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
}
private suspend fun sendPublicReply(
accountNpub: String,
targetEventId: String,
@@ -60,12 +60,16 @@ object NotificationUtils {
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
const val KEY_NOTIFICATION_ID = "key_notification_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
const val KEY_MARMOT_GROUP_ID = "key_marmot_group_id"
const val KEY_MARMOT_REPLY_TO_INNER_ID = "key_marmot_reply_to_inner_id"
const val KEY_MARMOT_REPLY_TO_INNER_AUTHOR = "key_marmot_reply_to_inner_author"
private const val DM_SUMMARY_ID = 0x10000
private const val ZAP_SUMMARY_ID = 0x20000
@@ -374,6 +378,9 @@ object NotificationUtils {
accountNpub: String? = null,
accountPictureUrl: String? = null,
chatroomMembers: String? = null,
marmotNostrGroupId: String? = null,
marmotReplyToInnerEventId: String? = null,
marmotReplyToInnerAuthor: String? = null,
) {
getOrCreateDMChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
@@ -390,6 +397,9 @@ object NotificationUtils {
accountNpub = accountNpub,
accountPictureUrl = accountPictureUrl,
chatroomMembers = chatroomMembers,
marmotNostrGroupId = marmotNostrGroupId,
marmotReplyToInnerEventId = marmotReplyToInnerEventId,
marmotReplyToInnerAuthor = marmotReplyToInnerAuthor,
)
}
@@ -425,6 +435,9 @@ object NotificationUtils {
accountNpub: String?,
accountPictureUrl: String?,
chatroomMembers: String?,
marmotNostrGroupId: String? = null,
marmotReplyToInnerEventId: String? = null,
marmotReplyToInnerAuthor: String? = null,
) {
val notId = id.hashCode()
@@ -522,6 +535,50 @@ object NotificationUtils {
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.build()
builder.addAction(replyAction)
} else if (accountNpub != null && marmotNostrGroupId != null) {
// Marmot/MLS Reply action: sends the user's text as an encrypted
// kind:9 inside the Marmot group, replying to the inner event
// that triggered this notification. Mirrors the NIP-17 path
// above but routes through NotificationReplyReceiver's
// MARMOT_REPLY_ACTION branch so we never publish a plaintext
// public reply for an encrypted group message.
val remoteInput =
RemoteInput
.Builder(KEY_REPLY_TEXT)
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
.build()
val replyIntent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = MARMOT_REPLY_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_ACCOUNT_NPUB, accountNpub)
putExtra(KEY_MARMOT_GROUP_ID, marmotNostrGroupId)
if (marmotReplyToInnerEventId != null) {
putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, marmotReplyToInnerEventId)
}
if (marmotReplyToInnerAuthor != null) {
putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, marmotReplyToInnerAuthor)
}
}
val replyPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
notId,
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val replyAction =
NotificationCompat.Action
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.build()
builder.addAction(replyAction)
}
@@ -118,6 +118,37 @@ fun ControlWhenPlayerIsActive(
}
}
/**
* Pauses [mediaControllerState] when the host activity leaves the foreground.
*
* Standalone version of the ON_PAUSE arm of [ControlWhenPlayerIsActive] for
* callers (e.g. voice notes) that don't have the visibility mutex / auto-resume
* logic but still need to stop playback when the app backgrounds. Pairs with
* the 30s release timer in [GetVideoController] — pause immediately, release
* the controller after the timeout, reassemble on resume.
*
* Skips the explicit BackgroundMedia (PiP) instance: that one is opted in to
* keep-playing.
*/
@Composable
fun PauseControllerWhenInBackground(mediaControllerState: MediaControllerState) {
val controller = mediaControllerState.controller
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, mediaControllerState) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE &&
controller.isPlaying &&
!BackgroundMedia.isMutex(mediaControllerState)
) {
controller.pause()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
class PlayerEventListener(
val view: View,
) : Player.Listener {
@@ -21,74 +21,172 @@
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
internal const val BACKGROUND_RELEASE_TIMEOUT_MS = 30_000L
@Composable
fun GetVideoController(
mediaItem: LoadedMediaItem,
muted: Boolean = false,
play: Boolean = false,
// Opt-out for callers whose lifecycle owner is already a background-playback
// surface (PiP): there, the 30s timer would fire as soon as the activity
// enters PiP mode and race the controller build / `RegisterBackgroundMedia`
// registration, killing the just-attached controller and blanking the
// window. The opt-out skips the timer entirely for those callers.
releaseOnBackgroundTimeout: Boolean = true,
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
) {
val context = LocalContext.current
val controllerState by remember(mediaItem) {
PlaybackServiceClient
.controllerAsFlow(
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
).onEach { state ->
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
// Read first and only push an IPC if the value actually needs to change —
// with several feed videos preloading at once each volume= write was a
// round-trip to the service for nothing.
val targetVolume =
when {
BackgroundMedia.isPlaying() -> 0f
muted -> 0f
else -> 1f
// After the app has been in the background for BACKGROUND_RELEASE_TIMEOUT_MS,
// drop the MediaController so the underlying ExoPlayer + codec/buffer can be
// returned to the pool. On resume the flow is rebuilt, the new session reuses
// the same paused player from the warm pool (keyed by URI), and the onEach
// warm-pool fast path keeps position and buffered data intact.
val keepAlive = remember { mutableStateOf(true) }
val controllerState by remember(mediaItem, keepAlive.value) {
if (keepAlive.value) {
PlaybackServiceClient
.controllerAsFlow(
videoUri = mediaItem.src.videoUri,
proxyPort = mediaItem.src.proxyPort,
keepPlaying = mediaItem.src.keepPlaying,
context = context,
).onEach { state ->
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
// Read first and only push an IPC if the value actually needs to change —
// with several feed videos preloading at once each volume= write was a
// round-trip to the service for nothing.
val targetVolume =
when {
BackgroundMedia.isPlaying() -> 0f
muted -> 0f
else -> 1f
}
if (state.controller.volume != targetVolume) {
state.controller.volume = targetVolume
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
}
if (state.controller.volume != targetVolume) {
state.controller.volume = targetVolume
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
}
if (play) {
state.controller.playWhenReady = true
}
if (play) {
state.controller.playWhenReady = true
}
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
// buffer for this exact MediaItem, the MediaController's local mirror already
// shows the matching mediaId. Calling setMediaItem in that case would reset the
// player and discard the buffer — exactly what the warm pool exists to avoid.
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
// buffer for this exact MediaItem, the MediaController's local mirror already
// shows the matching mediaId. Calling setMediaItem in that case would reset the
// player and discard the buffer — exactly what the warm pool exists to avoid.
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
}
}
}
} else {
flowOf<MediaControllerState?>(null)
}
}.collectAsState(null)
if (releaseOnBackgroundTimeout) {
ReleaseControllerWhenBackgroundedFor(
timeoutMs = BACKGROUND_RELEASE_TIMEOUT_MS,
controllerState = controllerState,
keepAlive = keepAlive,
)
}
controllerState?.let {
inner(it)
}
}
/**
* Flips [keepAlive] to `false` after the host activity has been at ON_PAUSE for
* [timeoutMs], so the gated flow upstream releases the MediaController. ON_RESUME
* cancels any pending timer and flips it back to `true` so the controller is
* reacquired.
*
* The BackgroundMedia (PiP) controller is exempt — it's opted into background
* playback and must keep its MediaController alive past the timeout.
*/
@Composable
private fun ReleaseControllerWhenBackgroundedFor(
timeoutMs: Long,
controllerState: MediaControllerState?,
keepAlive: MutableState<Boolean>,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val currentControllerState by rememberUpdatedState(controllerState)
DisposableEffect(lifecycleOwner, keepAlive) {
val scope = CoroutineScope(Dispatchers.Main)
var timeoutJob: Job? = null
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
timeoutJob?.cancel()
timeoutJob =
scope.launch {
delay(timeoutMs)
val cs = currentControllerState
if (cs == null || !BackgroundMedia.isMutex(cs)) {
keepAlive.value = false
}
}
}
Lifecycle.Event.ON_RESUME -> {
timeoutJob?.cancel()
timeoutJob = null
keepAlive.value = true
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
timeoutJob?.cancel()
lifecycleOwner.lifecycle.removeObserver(observer)
scope.cancel()
}
}
}
@@ -52,7 +52,13 @@ class PipVideoActivity : ComponentActivity() {
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
GetMediaItem(mediaItemData) { mediaItem ->
GetVideoController(mediaItem, muted, true) { controllerState ->
GetVideoController(
mediaItem = mediaItem,
muted = muted,
play = true,
// PiP IS the opt-in for background playback — never release on background.
releaseOnBackgroundTimeout = false,
) { controllerState ->
// PiP window is small, keep bandwidth low by forcing the lowest
// rendition. User can still manually change quality via controls.
ApplyInitialVideoQuality(
@@ -143,11 +143,33 @@ class MediaSessionPool(
reset(mediaSession, keepPlaying)
// Warm-pool fast path acquires a player that still holds its MediaItem, so the
// client side skips setMediaItem (see GetVideoController) and onAddMediaItems
// never fires for this fresh session — leaving the notification's tap target
// unset. Re-bind it from the player's current item so tapping the playback
// notification opens the originating nostr URI.
bindSessionActivity(mediaSession, mediaSession.player.currentMediaItem)
cache.put(mediaSession.id, SessionListener(mediaSession, listener))
return mediaSession
}
fun bindSessionActivity(
session: MediaSession,
mediaItem: MediaItem?,
) {
val callbackUri = mediaItem?.mediaMetadata?.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI) ?: return
session.setSessionActivity(
PendingIntent.getActivity(
appContext,
0,
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), appContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
}
fun releaseSession(session: MediaSession) {
val listener = playingMap.get(session.id) ?: cache.get(session.id)
if (listener != null) {
@@ -220,16 +242,7 @@ class MediaSessionPool(
mediaItems: List<MediaItem>,
): ListenableFuture<List<MediaItem>> {
// set up return call when clicking on the Notification bar
mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI)?.let {
mediaSession.setSessionActivity(
PendingIntent.getActivity(
appContext,
0,
Intent(Intent.ACTION_VIEW, it.toUri(), appContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
}
pool.bindSessionActivity(mediaSession, mediaItems.firstOrNull())
return Futures.immediateFuture(mediaItems)
}
@@ -20,7 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.playback.service
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
@@ -137,6 +140,29 @@ class PlaybackService : MediaSessionService() {
Log.d("PlaybackService", "PlaybackService.onCreate")
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int =
try {
super.onStartCommand(intent, flags, startId)
} catch (e: IllegalStateException) {
// Media3's MediaSessionService.onStartCommand can call stopSelfSafely() when there is
// no active playback to handle a delivered intent (e.g. a MEDIA_BUTTON from a headset
// arriving while the app is backgrounded). stopSelfSafely() invokes startForeground()
// to detach the foreground notification before stopping, which Android 12+ rejects
// from the background with ForegroundServiceStartNotAllowedException. There is no
// playback to keep alive in this path, so swallow it and stop the service.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
Log.w("PlaybackService") { "Foreground service start not allowed; stopping PlaybackService" }
stopSelf()
START_NOT_STICKY
} else {
throw e
}
}
override fun onDestroy() {
Log.d("PlaybackService", "PlaybackService.onDestroy")
@@ -55,6 +55,17 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
fun since(key: T) = latestEOSEs.since(user(key), list(key))
/**
* Drops the EOSE state for the (user, list) pair this key resolves to so the next subscription
* assembly will ask the relay from scratch instead of carrying over a stale `since` cursor.
* Use when the in-memory note store may have evicted previously-loaded events (e.g.
* [LargeSoftCache]'s WeakReference store under memory pressure) and a list switch needs to
* re-fetch them rather than rely on a cache that's no longer there.
*/
protected fun clearEoseFor(key: T) {
latestEOSEs.clear(user(key), list(key))
}
fun newEose(
key: T,
relay: NormalizedRelayUrl,
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
@@ -58,8 +59,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.Publ
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
@@ -110,6 +113,7 @@ class RelaySubscriptionsCoordinator(
val polls = PollsFilterAssembler(client)
val pictures = PicturesFilterAssembler(client)
val calendars = CalendarsFilterAssembler(client)
val products = ProductsFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
val publicChats = PublicChatsFilterAssembler(client)
@@ -119,6 +123,7 @@ class RelaySubscriptionsCoordinator(
val nestRoomLiveness = NestRoomLivenessAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client)
@@ -127,6 +132,9 @@ class RelaySubscriptionsCoordinator(
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
// active when the wallet's on-chain transactions screen is on top.
val onchainZaps = OnchainZapsFilterAssembler(client)
val all =
listOf(
account,
@@ -137,6 +145,7 @@ class RelaySubscriptionsCoordinator(
discovery,
polls,
pictures,
calendars,
products,
shorts,
publicChats,
@@ -147,6 +156,7 @@ class RelaySubscriptionsCoordinator(
nestRoomLiveness,
longs,
articles,
softwareApps,
badges,
profileBadges,
browseEmojiSets,
@@ -167,6 +177,7 @@ class RelaySubscriptionsCoordinator(
relayInfoNip66,
chess,
nwc,
onchainZaps,
)
fun destroy() = all.forEach { it.destroy() }
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -31,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@Stable
class ChannelFinderQueryState(
val channel: Channel,
val account: Account,
)
@Stable
@@ -24,24 +24,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChannelFinderFilterAssemblerSubscription(
channel: Channel,
accountViewModel: AccountViewModel,
) = ChannelFinderFilterAssemblerSubscription(channel, accountViewModel.dataSources().channelFinder)
) = ChannelFinderFilterAssemblerSubscription(channel, accountViewModel.account, accountViewModel.dataSources().channelFinder)
@Composable
fun ChannelFinderFilterAssemblerSubscription(
channel: Channel,
account: Account,
dataSource: ChannelFinderFilterAssemblyGroup,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(channel) {
ChannelFinderQueryState(channel)
remember(channel, account) {
ChannelFinderQueryState(channel, account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -34,29 +36,28 @@ fun filterMissingChannelsById(keys: List<ChannelFinderQueryState>): List<RelayBa
mapOfSet {
keys.forEach { key ->
if (key.channel is PublicChatChannel && key.channel.event == null) {
key.channel.relays().forEach {
val searchRelays =
key.account.searchRelayList.flow.value
.ifEmpty { DefaultSearchRelayList }
val indexerRelays =
key.account.indexerRelayList.flow.value
.ifEmpty { DefaultIndexerRelayList }
(key.channel.relays() + searchRelays + indexerRelays).forEach {
add(it, key.channel.idHex)
}
} else {
null
}
}
}
if (relayPerChannel.isEmpty()) return emptyList()
return relayPerChannel.mapNotNull {
if (it.value.isEmpty()) {
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = filterMissingPublicChannelsByIdKinds,
ids = it.value.sorted(),
),
)
} else {
null
}
return relayPerChannel.map { (relay, channelIds) ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = filterMissingPublicChannelsByIdKinds,
ids = channelIds.sorted(),
),
)
}
}
@@ -333,7 +333,10 @@ fun observeNoteReferences(
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
// Subscribe in the LocalCache for changes that arrive in the device.
// On-chain zaps piggyback `note.flow().zaps.stateFlow` because Note.addOnchainZap
// invalidates the same `flowSet.zaps`. If that ever moves to a dedicated flow,
// add it here too — otherwise on-chain-only notes will stop pinging the chevron.
val flow =
remember(note) {
combine(
@@ -24,17 +24,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
fun filterNWCPaymentsFromRequests(
serviceKeys: Set<HexKey>,
paymentRequests: Set<HexKey>,
fromUsers: Set<HexKey>,
): Filter =
// The request event id (#e) is a unique 32-byte identifier — sufficient on
// its own to match the response. Adding `authors` or `#p` makes the filter
// strictly stricter at the relay, which causes silent timeouts on services
// or relays that don't set / index those fields the way NIP-47 expects
// (e.g. wallets that omit the `p` tag, relays that don't index single-letter
// tags on ephemeral kinds). Primal's client uses the same minimal filter
// (kinds + #e), and the wallet's identity is still authenticated end-to-end
// by NIP-04 decryption against the per-connection shared secret.
fun filterNWCPaymentsFromRequests(paymentRequests: Set<HexKey>): Filter =
Filter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = serviceKeys.sorted(),
tags =
mapOf(
"e" to paymentRequests.sorted(),
"p" to fromUsers.sorted(),
),
tags = mapOf("e" to paymentRequests.sorted()),
)
@@ -49,11 +49,9 @@ fun NWCFinderFilterAssemblerSubscription(
remember(note) {
val zapPaymentRequestNote = note
(zapPaymentRequestNote.event as? LnZapPaymentRequestEvent)?.let { noteEvent ->
noteEvent.walletServicePubKey()?.let { serviceId ->
noteEvent.walletServicePubKey()?.let {
zapPaymentRequestNote.relays.map {
NWCPaymentQueryState(
fromServiceHex = serviceId,
toUserHex = noteEvent.pubKey,
replyingToHex = noteEvent.id,
relay = it,
)
@@ -26,11 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
// This allows multiple screen to be listening to tags, even the same tag
// This allows multiple screen to be listening to tags, even the same tag.
// The subscription filter only needs the request event id (#e) — the wallet
// service's identity is authenticated by NIP-04 decryption against the
// per-connection shared secret, not by relay-side `authors`/`#p` filtering.
@Stable
class NWCPaymentQueryState(
val fromServiceHex: HexKey,
val toUserHex: HexKey,
val replyingToHex: HexKey,
val relay: NormalizedRelayUrl,
)
@@ -48,5 +49,17 @@ class NWCPaymentFilterAssembler(
override fun invalidateKeys() = invalidateFilters()
/**
* Synchronously sends the REQ frame to the relay, bypassing the 500ms
* BundledUpdate debounce. Used for NIP-47 RPC where the response is an
* ephemeral event (kind 23195) and the subscription must be active on the
* relay before we publish the request event — otherwise the relay drops
* the response with no replay.
*/
fun subscribeAndFlush(query: NWCPaymentQueryState) {
subscribe(query)
group.forEach { it.forceInvalidate() }
}
override fun destroy() = group.forEach { it.destroy() }
}
@@ -32,15 +32,13 @@ class NWCPaymentWatcherSubAssembler(
if (keys.isEmpty()) return null
return keys.groupBy { it.relay }.map { relayGroup ->
val fromAuthors = relayGroup.value.mapTo(mutableSetOf()) { it.fromServiceHex }
val replyingToPayments = relayGroup.value.mapTo(mutableSetOf()) { it.replyingToHex }
val aboutUsers = relayGroup.value.mapTo(mutableSetOf()) { it.toUserHex }
if (fromAuthors.isEmpty() || replyingToPayments.isEmpty()) return null
if (replyingToPayments.isEmpty()) return null
RelayBasedFilter(
relay = relayGroup.key,
filter = filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers),
filter = filterNWCPaymentsFromRequests(replyingToPayments),
)
}
}
@@ -48,7 +48,7 @@ class UserFinderFilterAssembler(
val group =
listOf(
UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys),
UserWatcherSubAssembler(client, cache, ::allKeys),
UserWatcherSubAssembler(client, cache, failureTracker, ::allKeys),
UserReportsSubAssembler(client, cache, ::allKeys),
UserCardsSubAssembler(client, cache, ::allKeys),
)
@@ -20,12 +20,15 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
@@ -107,16 +110,46 @@ class UserOutboxFinderSubAssembler(
hasTried,
)
return perRelayKeysBoth.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
)
} else {
null
val activeFilters =
perRelayKeysBoth.mapNotNull {
val sortedUsers = it.value.sorted()
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
)
} else {
null
}
}
}
// Users that pickRelaysToLoadUsers could not place anywhere this pass:
// every candidate relay tier (outbox/hints/index/search/connected/common)
// is already in hasTried. Without a fallback they'd be silently dropped
// and a late-published kind 10002 would never reach the UI.
val placedPubkeys =
perRelayKeysBoth.values
.asSequence()
.flatten()
.toSet()
val abandonedPubkeys =
noOutboxList.mapNotNullTo(mutableSetOf<HexKey>()) { user ->
user.pubkeyHex.takeIf { it !in placedPubkeys }
}
if (abandonedPubkeys.isEmpty()) return activeFilters
val sortedAbandoned = abandonedPubkeys.sorted()
val fallbackRelays =
(DefaultIndexerRelayList + DefaultSearchRelayList) - failureTracker.cannotConnectRelays
val fallbackFilters =
fallbackRelays.map { relay ->
RelayBasedFilter(
relay = relay,
filter = Filter(kinds = relayListKinds, authors = sortedAbandoned),
)
}
return activeFilters + fallbackFilters
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -43,11 +44,13 @@ val UserMetadataForKeyKinds =
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
KeyPackageRelayListEvent.KIND,
PaymentTargetsEvent.KIND,
)
fun filterUserMetadataForKey(
authors: Set<User>,
indexRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
since: EOSEAccountFast<User>,
): List<RelayBasedFilter> {
val perRelayUsers =
@@ -57,7 +60,7 @@ fun filterUserMetadataForKey(
key.outboxRelays()
?: (key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays)
relays.forEach {
(relays - cannotConnectRelays).forEach {
add(it, key)
}
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class UserWatcherSubAssembler(
client: INostrClient,
val cache: LocalCache,
val failureTracker: RelayOfflineTracker,
allKeys: () -> Set<UserFinderQueryState>,
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
/**
@@ -99,7 +101,13 @@ class UserWatcherSubAssembler(
)
}
val newFilters = filterUserMetadataForKey(users, indexRelays, latestEOSEs).ifEmpty { null }
val newFilters =
filterUserMetadataForKey(
users,
indexRelays,
failureTracker.cannotConnectRelays,
latestEOSEs,
).ifEmpty { null }
sub.updateFilters(newFilters?.groupByRelay())
}
@@ -51,6 +51,11 @@ open class EOSEByKey<U : Any>(
fun since(listCode: U) = followList[listCode]?.relayList
/** Drops the EOSE state for [listCode] so the next filter assembly starts fresh. */
fun clear(listCode: U) {
followList.remove(listCode)
}
fun newEose(
listCode: U,
relayUrl: NormalizedRelayUrl,
@@ -83,6 +88,19 @@ open class EOSEAccountKey<U : Any>(
users.remove(user)
}
/**
* Drops the EOSE state for the (user, list) pair so the next assembly will ask the relay
* from scratch. Used by feeds that need to force a re-fetch — e.g. calendar / pictures
* where [LargeSoftCache]'s WeakReference store may have evicted the previously-loaded notes
* while the user was viewing a different list.
*/
fun clear(
user: User,
listCode: U,
) {
users[user]?.clear(listCode)
}
fun since(
key: User,
listCode: U,
@@ -49,6 +49,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.UriParser
import kotlinx.coroutines.CancellationException
@@ -192,7 +194,7 @@ fun uriToRoute(
routeFor(
note = LocalCache.getOrCreateAddressableNote(nip19.address()),
loggedIn = account,
) ?: Route.EventRedirect(nip19.aTag())
) ?: calendarDirectRoute(nip19) ?: Route.EventRedirect(nip19.aTag())
}
is NEmbed -> {
@@ -254,3 +256,19 @@ fun uriToRoute(
return null
}
/**
* Direct route for an `naddr` whose event hasn't arrived in [LocalCache] yet. When a notification
* is tapped (or a `nostr:naddr…` deep link arrives) for a calendar appointment we know is kind
* 31922/31923, route straight to the dedicated detail screen instead of bouncing through
* [Route.EventRedirect]. The detail screen issues its own per-event subscription, so the user
* sees the calendar-specific loading placeholder while the event arrives, not the generic
* redirect screen.
*/
private fun calendarDirectRoute(nip19: NAddress): Route? =
when (nip19.kind) {
CalendarTimeSlotEvent.KIND,
CalendarDateSlotEvent.KIND,
-> Route.CalendarEventDetail(nip19.kind, nip19.author, nip19.dTag)
else -> null
}
@@ -36,12 +36,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -63,7 +63,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -300,8 +300,9 @@ fun EditPostView(
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.message.setTextAndPlaceCursorAtEnd(
postViewModel.message.text.toString() + "\n\n" + it,
)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
@@ -309,43 +310,6 @@ fun EditPostView(
}
}
}
/*
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp, horizontal = Size10dp),
) {
Column {
Text(
text = stringRes(R.string.message_to_author),
fontSize = 18.sp,
fontWeight = FontWeight.W500,
)
HorizontalDivider(thickness = DividerThickness)
MyTextField(
value = postViewModel.subject,
onValueChange = { postViewModel.updateSubject(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringRes(R.string.message_to_author_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
),
)
}
}*/
}
}
@@ -416,9 +380,11 @@ private fun MessageField(postViewModel: EditPostViewModel) {
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = { postViewModel.updateMessage(it) },
OutlinedThinPaddingTextField(
state = postViewModel.message,
onTextChanged = { postViewModel.onMessageChanged() },
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -447,7 +413,6 @@ private fun MessageField(postViewModel: EditPostViewModel) {
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -77,7 +79,7 @@ open class EditPostViewModel : ViewModel() {
var nip95attachments by
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
val message = TextFieldState()
var urlPreview by mutableStateOf<String?>(null)
val mediaUploadTracker = MediaUploadTracker()
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
@@ -111,7 +113,7 @@ open class EditPostViewModel : ViewModel() {
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
multiOrchestrator = null
message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
message.setTextAndPlaceCursorAtEnd(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
urlPreview = findUrlInMessage()
this.editedFromNote = edit
@@ -141,7 +143,7 @@ open class EditPostViewModel : ViewModel() {
}
account.sendEdit(
message = message.text,
message = message.text.toString(),
originalNote = editedFromNote!!,
notify = notify,
summary = subject.text.ifBlank { null },
@@ -248,7 +250,7 @@ open class EditPostViewModel : ViewModel() {
}
}
message = message.insertUrlAtCursor(urls.joinToString(" "))
message.insertUrlAtCursor(urls.joinToString(" "))
urlPreview = findUrlInMessage()
this@EditPostViewModel.multiOrchestrator = null
@@ -264,7 +266,7 @@ open class EditPostViewModel : ViewModel() {
}
open fun cancel() {
message = TextFieldValue("")
message.setTextAndPlaceCursorAtEnd("")
subject = TextFieldValue("")
editedFromNote = null
@@ -280,17 +282,16 @@ open class EditPostViewModel : ViewModel() {
}
open fun findUrlInMessage(): String? =
message.text.split('\n').firstNotNullOfOrNull { paragraph ->
message.text.toString().split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
}
}
open fun updateMessage(it: TextFieldValue) {
message = it
open fun onMessageChanged() {
urlPreview = findUrlInMessage()
if (it.selection.collapsed) {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
@@ -305,7 +306,7 @@ open class EditPostViewModel : ViewModel() {
open fun autocompleteWithUser(item: User) {
userSuggestions?.let { userSuggestions ->
val lastWord = message.currentWord()
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
userSuggestions.replaceCurrentWord(message, lastWord, item)
userSuggestionsMainMessage = null
userSuggestions.reset()
@@ -1,229 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.util.Patterns
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlin.coroutines.cancellation.CancellationException
data class RangesChanges(
val original: TextRange,
val modified: TextRange,
)
class UrlUserTagTransformation(
val color: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText = buildAnnotatedStringWithUrlHighlighting(text, color)
}
fun buildAnnotatedStringWithUrlHighlighting(
text: AnnotatedString,
color: Color,
): TransformedText {
val substitutions = mutableListOf<RangesChanges>()
val newText =
buildAnnotatedString {
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
append(
text.text.split('\n').joinToString("\n") { paragraph: String ->
paragraph.split(' ').joinToString(" ") { word: String ->
try {
val mention = parseUserMention(word)
if (mention != null) {
val (keyPortion, restOfWord, hexKey) = mention
val startIndex = builderBefore.toString().length
builderBefore.append(
"$keyPortion$restOfWord ",
) // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyPortion.length
val user = LocalCache.getOrCreateUser(hexKey)
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
builderAfter.append(
"$newWord$restOfWord ",
) // accounts for the \n at the end of each paragraph
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, startNew + newWord.length),
),
)
newWord + restOfWord
} else if (Patterns.WEB_URL.matcher(word).matches()) {
val startIndex = builderBefore.toString().length
val endIndex = startIndex + word.length
val startNew = builderAfter.toString().length
val endNew = startNew + word.length
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, endNew),
),
)
builderBefore.append("$word ")
builderAfter.append("$word ")
word
} else {
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// if it can't parse the key, don't try to change.
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
}
},
)
substitutions.forEach {
addStyle(
style =
SpanStyle(
color = color,
textDecoration = TextDecoration.None,
),
start = it.modified.start,
end = it.modified.end,
)
}
}
val numberOffsetTranslator =
object : OffsetMapping {
// Treat each substitution as an atomic wedge: any cursor position that falls
// strictly inside a substituted range snaps to the wedge's trailing edge.
// Without this, an IME (e.g. SwiftKey in extracted-text mode) can place the
// cursor in the middle of an "@npub1..." mention, and a subsequent backspace
// deletes a char from inside the bech32, breaking the npub and "expanding"
// the collapsed mention.
override fun originalToTransformed(offset: Int): Int {
val inInsideRange =
substitutions.firstOrNull { offset > it.original.start && offset < it.original.end }
if (inInsideRange != null) {
return inInsideRange.modified.end
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
return if (lastRangeThrough != null) {
lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
} else {
offset
}
}
override fun transformedToOriginal(offset: Int): Int {
val inInsideRange =
substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end }
if (inInsideRange != null) {
return inInsideRange.original.end
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
return if (lastRangeThrough != null) {
lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
} else {
offset
}
}
}
return TransformedText(
newText,
numberOffsetTranslator,
)
}
private data class UserMention(
val keyPortion: String,
val restOfWord: String,
val hexKey: String,
)
private fun parseUserMention(word: String): UserMention? {
var key = word
val prefix: String
if (key.startsWith("nostr:", true)) {
prefix = key.substring(0, 6)
key = key.substring(6)
} else if (key.startsWith("@")) {
prefix = "@"
key = key.substring(1)
} else {
return null
}
if (key.startsWith("npub1", true) && key.length >= 63) {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null
return UserMention("$prefix$keyB32", restOfWord, hex)
} else if (key.startsWith("nprofile1", true)) {
val parsed = Nip19Parser.uriToRoute(key) ?: return null
val entity = parsed.entity
if (entity !is NProfile && entity !is NPub) return null
val hex =
when (entity) {
is NProfile -> entity.hex
is NPub -> entity.hex
else -> return null
}
val bech32Len = parsed.nip19raw.length
val restOfWord = key.substring(bech32Len)
return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex)
}
return null
}
@@ -38,6 +38,7 @@ enum class ServerType {
val DEFAULT_MEDIA_SERVERS: List<ServerName> =
listOf(
ServerName("Nostr.Build", "https://blossom.band/", ServerType.Blossom),
ServerName("Nostrcheck.me", "https://cdn.nostrcheck.me", ServerType.Blossom),
ServerName("24242.io", "https://24242.io/", ServerType.Blossom),
ServerName("Azzamo", "https://blossom.azzamo.media", ServerType.Blossom),
ServerName("YakiHonne", "https://blossom.yakihonne.com/", ServerType.Blossom),
@@ -0,0 +1,177 @@
/*
* 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.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* Outlined sibling of [ThinPaddingTextField]. Wraps the new [TextFieldState] API
* with [OutlinedTextFieldDefaults.DecorationBox] so callers that want the
* Material3 outlined look (visible rounded border, transparent inside, notched
* label) can use the same `outputTransformation` / `inputTransformation`
* mention pipeline as the filled `ThinPaddingTextField`.
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun OutlinedThinPaddingTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
onTextChanged: (() -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource? = null,
shape: Shape = OutlinedTextFieldDefaults.shape,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors(),
contentPadding: PaddingValues =
OutlinedTextFieldDefaults.contentPadding(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
),
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
if (onTextChanged != null) {
val callback by rememberUpdatedState(onTextChanged)
LaunchedEffect(state) {
snapshotFlow { state.text }
.collect { callback() }
}
}
val textColor =
textStyle.color.takeOrElse {
val focused by interactionSource.collectIsFocusedAsState()
when {
!enabled -> MaterialTheme.colorScheme.placeholderText
isError -> MaterialTheme.colorScheme.onSurface
focused -> MaterialTheme.colorScheme.onSurface
else -> MaterialTheme.colorScheme.onSurface
}
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
val lineLimits =
if (singleLine) {
TextFieldLineLimits.SingleLine
} else {
TextFieldLineLimits.MultiLine(minLines, maxLines)
}
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
state = state,
modifier =
modifier
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = 36.dp,
),
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = keyboardOptions,
lineLimits = lineLimits,
interactionSource = interactionSource,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
decorator = { innerTextField ->
OutlinedTextFieldDefaults.DecorationBox(
value = state.text.toString(),
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = contentPadding,
container = {
OutlinedTextFieldDefaults.Container(
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
shape = shape,
)
},
)
},
)
}
}
@@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -59,11 +60,13 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -104,6 +107,12 @@ fun ReusableZapButton(
callbacks: ZapButtonCallbacks = ZapButtonCallbacks(),
) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
var onchainZapAmount by remember { mutableStateOf<Long?>(null) }
var showOnchainDialog by remember { mutableStateOf(false) }
val onchainZapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
.collectAsStateWithLifecycle()
// Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons)
if (config.showUserFinderSubscription) {
@@ -192,6 +201,26 @@ fun ReusableZapButton(
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
onchainZapAmountChoices = onchainZapAmountChoices,
onOnchainAmount = { amount ->
wantsToZap = null
onchainZapAmount = amount
showOnchainDialog = true
},
)
}
if (showOnchainDialog) {
val zappedEventHint = remember(baseNote) { baseNote.toEventHint<Event>() }
OnchainZapSendDialog(
accountViewModel = accountViewModel,
onDismiss = {
showOnchainDialog = false
onchainZapAmount = null
},
recipientPubKey = baseNote.author?.pubkeyHex,
zappedEvent = zappedEventHint,
prefillAmountSats = onchainZapAmount,
)
}
@@ -82,7 +82,13 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
note.event?.let { SensitivityWarning(it, accountViewModel, content) }
val noteEvent = note.event
if (noteEvent == null) {
content()
} else {
SensitivityWarning(noteEvent, accountViewModel, content)
}
}
@Composable
@@ -109,6 +109,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.ZoomState
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@@ -129,6 +130,11 @@ fun ZoomableImageDialog(
// viewport — aligns with the tapped thumbnail at progress = 0.
var imageBounds by remember { mutableStateOf<Rect?>(null) }
// ZoomState of the currently-visible image, hoisted from RenderImageOrVideo so the
// grow/shrink animation can read live scale/offset and start the exit from the
// image's actual on-screen bounds when the user has zoomed in.
var currentZoomState by remember { mutableStateOf<ZoomState?>(null) }
// Start the enter animation as soon as valid image bounds are available. Without
// this gate, the animation can begin before onGloballyPositioned has reported real
// bounds and the graphicsLayer falls back to its alpha-only branch.
@@ -209,6 +215,8 @@ fun ZoomableImageDialog(
sourceBounds = sourceBounds,
imageBounds = imageBounds,
onImageBoundsChanged = updateImageBounds,
currentZoomState = currentZoomState,
onZoomStateChanged = { currentZoomState = it },
progress = progressProvider,
onDismiss = dismissWithAnimation,
accountViewModel = accountViewModel,
@@ -225,6 +233,8 @@ private fun DialogContent(
sourceBounds: Rect?,
imageBounds: Rect?,
onImageBoundsChanged: (Rect) -> Unit,
currentZoomState: ZoomState?,
onZoomStateChanged: (ZoomState) -> Unit,
progress: () -> Float,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
@@ -281,18 +291,32 @@ private fun DialogContent(
src.width > 0f && src.height > 0f &&
img.width > 0f && img.height > 0f
) {
// Account for user-applied zoom: the image is rendered at
// imageBounds transformed by the inner zoomable (uniform scale
// around the layout center, then offset). The exit animation
// must start from those visible bounds, not the unzoomed layout
// bounds — otherwise dismissing a zoomed-in image jumps.
val zoom = currentZoomState
val zScale = zoom?.scale ?: 1f
val zOffX = zoom?.offsetX ?: 0f
val zOffY = zoom?.offsetY ?: 0f
val imgCenter = img.center
val zoomedWidth = img.width * zScale
val zoomedHeight = img.height * zScale
val zoomedCenterX = imgCenter.x + zOffX
val zoomedCenterY = imgCenter.y + zOffY
transformOrigin = TransformOrigin(0f, 0f)
// Uniform scale so non-square images keep their aspect ratio during
// the grow animation. max() so the image covers the source rect in
// at least one dimension; the other overflows centered on the tap.
val startScale = maxOf(src.width / img.width, src.height / img.height)
val startScale = maxOf(src.width / zoomedWidth, src.height / zoomedHeight)
val srcCenter = src.center
val imgCenter = img.center
val p = progress()
scaleX = lerp(startScale, 1f, p)
scaleY = lerp(startScale, 1f, p)
translationX = lerp(srcCenter.x - startScale * imgCenter.x, 0f, p)
translationY = lerp(srcCenter.y - startScale * imgCenter.y, 0f, p)
translationX = lerp(srcCenter.x - startScale * zoomedCenterX, 0f, p)
translationY = lerp(srcCenter.y - startScale * zoomedCenterY, 0f, p)
} else {
// No source bounds: fall back to a plain fade.
alpha = progress()
@@ -305,6 +329,7 @@ private fun DialogContent(
) { index ->
allImages.getOrNull(index)?.let { pageContent ->
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val isCurrent = index == pagerState.currentPage
RenderImageOrVideo(
content = pageContent,
roundedCorner = false,
@@ -312,11 +337,9 @@ private fun DialogContent(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
onContentBoundsChanged =
if (index == pagerState.currentPage) {
onImageBoundsChanged
} else {
null
},
if (isCurrent) onImageBoundsChanged else null,
onZoomStateChanged =
if (isCurrent) onZoomStateChanged else null,
)
}
}
@@ -330,6 +353,7 @@ private fun DialogContent(
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
onContentBoundsChanged = onImageBoundsChanged,
onZoomStateChanged = onZoomStateChanged,
)
}
}
@@ -517,6 +541,7 @@ private fun RenderImageOrVideo(
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
onContentBoundsChanged: ((Rect) -> Unit)? = null,
onZoomStateChanged: ((ZoomState) -> Unit)? = null,
) {
val contentScale =
if (isFiniteHeight) {
@@ -534,6 +559,13 @@ private fun RenderImageOrVideo(
Modifier.fillMaxWidth()
}
// Hoist ZoomState so the parent dialog can read live scale/offset and start the
// exit animation from the image's actual on-screen bounds when zoomed in.
val zoomState = rememberZoomState()
LaunchedEffect(zoomState, onZoomStateChanged) {
onZoomStateChanged?.invoke(zoomState)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = rowModifier) {
when (content) {
is MediaUrlImage -> {
@@ -541,7 +573,7 @@ private fun RenderImageOrVideo(
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
zoomState,
onTap = {
controllerVisible.value = !controllerVisible.value
},
@@ -606,7 +638,7 @@ private fun RenderImageOrVideo(
Modifier
.fillMaxWidth()
.zoomable(
rememberZoomState(),
zoomState,
onTap = {
controllerVisible.value = !controllerVisible.value
},
@@ -32,3 +32,12 @@ val DefaultFeedOrderEvent: Comparator<Event> =
val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
// Snapshots createdAt once per note so the comparator stays consistent even if
// another thread swaps a Note's event mid-sort (e.g. a newer AddressableEvent
// arriving from a relay). Avoids TimSort's "Comparison method violates its
// general contract!" IllegalArgumentException.
fun Iterable<Note>.sortedByDefaultFeedOrder(): List<Note> =
map { it to (it.createdAt() ?: 0L) }
.sortedWith(compareByDescending<Pair<Note, Long>> { it.second }.thenBy { it.first.idHex })
.map { it.first }
@@ -65,6 +65,8 @@ object ScrollStateKeys {
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
const val COMMUNITIES_LIST = "CommunitiesListFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
const val SHORTS_SCREEN = "ShortsFeed"
const val PUBLIC_CHATS_SCREEN = "PublicChatsFeed"
@@ -73,6 +73,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
@@ -93,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.contactList.ContactListUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
@@ -175,10 +182,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
@@ -251,6 +260,18 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
composableFromEndArgs<Route.CalendarEventDetail> {
CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav)
}
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
composableFromBottomArgs<Route.EditCalendarEvent> {
NewCalendarEventScreen(nav, accountViewModel, editKind = it.kind, editPubKeyHex = it.pubKeyHex, editDTag = it.dTag)
}
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
@@ -267,6 +288,7 @@ fun BuildNavigation(
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEnd<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
@@ -351,6 +373,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Profile> { ProfileScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
@@ -366,7 +389,15 @@ fun BuildNavigation(
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> {
MarmotGroupChatScreen(
nostrGroupId = it.nostrGroupId,
draftMessage = it.message,
replyToInnerNote = it.replyId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
@@ -50,6 +50,9 @@ enum class NavBarItem {
COMMUNITIES,
ARTICLES,
PICTURES,
SOFTWARE_APPS,
CALENDARS,
CALENDAR_COLLECTIONS,
SHORTS,
PUBLIC_CHATS,
FOLLOW_PACKS,
@@ -199,6 +202,27 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
),
NavBarItem.SOFTWARE_APPS to
NavBarItemDef(
id = NavBarItem.SOFTWARE_APPS,
labelRes = R.string.software_apps,
icon = MaterialSymbols.Apps,
resolveRoute = { Route.SoftwareApps },
),
NavBarItem.CALENDARS to
NavBarItemDef(
id = NavBarItem.CALENDARS,
labelRes = R.string.route_calendars,
icon = MaterialSymbols.CalendarMonth,
resolveRoute = { Route.Calendars },
),
NavBarItem.CALENDAR_COLLECTIONS to
NavBarItemDef(
id = NavBarItem.CALENDAR_COLLECTIONS,
labelRes = R.string.route_calendar_collections,
icon = MaterialSymbols.AutoMirrored.FormatListBulleted,
resolveRoute = { Route.CalendarCollections },
),
NavBarItem.SHORTS to
NavBarItemDef(
id = NavBarItem.SHORTS,
@@ -318,6 +342,9 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SOFTWARE_APPS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
@@ -149,6 +149,18 @@ fun routeForInner(
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
// Calendar appointments route to their dedicated detail screen rather than the generic
// Route.Note that AddressableEvent would fall through to — without this the notification
// tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar
// detail with RSVPs, participants, and the "in calendars" list.
is com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -> {
Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is GiftWrapEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
@@ -252,6 +264,15 @@ fun routeReplyTo(
note: Note,
account: Account,
): Route? {
// Marmot group messages must reply inside the encrypted group, not as a
// public kind:1111 comment. The inner kind:9 event has no group hint of
// its own — we detect the group via the gathering MarmotGroupChatroom,
// mirroring routeFor() above.
val marmotGroup = note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom }
if (marmotGroup != null) {
return Route.MarmotGroupChat(marmotGroup.nostrGroupId, replyId = note.idHex)
}
val noteEvent = note.event
return when (noteEvent) {
is ChannelMessageEvent -> {
@@ -81,6 +81,47 @@ sealed class Route {
@Serializable object Pictures : Route()
@Serializable object SoftwareApps : Route()
@Serializable object Calendars : Route()
@Serializable object CalendarCollections : Route()
@Serializable
data class NewCalendarEvent(
val draft: String? = null,
) : Route()
@Serializable
data class EditCalendarEvent(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable
data class NewCalendarCollection(
val dTag: String? = null,
) : Route()
@Serializable data class CalendarEventDetail(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object Products : Route()
@Serializable object Shorts : Route()
@@ -111,6 +152,8 @@ sealed class Route {
@Serializable object WalletTransactions : Route()
@Serializable object OnchainTransactions : Route()
@Serializable
data class WalletDetail(
val walletId: String,
@@ -241,6 +284,8 @@ sealed class Route {
@Serializable object NotificationSettings : Route()
@Serializable object CalendarReminderSettings : Route()
@Serializable object Lists : Route()
@Serializable data class MyPeopleListView(
@@ -307,6 +352,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ContactListUsers(
val noteId: String,
) : Route()
@Serializable data class Hashtag(
val hashtag: String,
) : Route()
@@ -402,6 +451,8 @@ sealed class Route {
@Serializable data class MarmotGroupChat(
val nostrGroupId: String,
val message: String? = null,
val replyId: HexKey? = null,
) : Route()
@Serializable data class MarmotGroupInfo(
@@ -334,13 +334,13 @@ private enum class FeedGroup(
@param:androidx.annotation.StringRes val labelRes: Int,
) {
FEEDS(R.string.feed_group_feeds),
RELAYS(R.string.feed_group_relays),
HASHTAGS(R.string.feed_group_hashtags),
INTEREST_SETS(R.string.feed_group_interest_sets),
COMMUNITIES(R.string.feed_group_communities),
LOCATIONS(R.string.feed_group_locations),
LISTS(R.string.feed_group_lists),
DVMS(R.string.feed_group_dvms),
RELAYS(R.string.feed_group_relays),
COMMUNITIES(R.string.feed_group_communities),
LISTS(R.string.feed_group_lists),
}
private fun FeedDefinition.group(): FeedGroup =
@@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -149,7 +150,7 @@ fun HiddenNote(
) {
if (hasExcessiveHashtags) {
Text(
text = stringRes(R.string.post_was_hidden_due_to_too_many_hashtags, hashtagLimit),
text = pluralStringResource(R.plurals.post_was_hidden_due_to_too_many_hashtags, hashtagLimit, hashtagLimit),
color = Color.Gray,
textAlign = TextAlign.Center,
)
@@ -21,23 +21,44 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder
@Composable
fun DisplayAuthorBanner(
note: Note,
baseNote: Note,
accountViewModel: AccountViewModel,
modifier: Modifier = SimpleImageBorder,
) {
WatchAuthor(note, accountViewModel) {
val noteAuthor = baseNote.author
if (noteAuthor != null) {
BannerImage(
it,
noteAuthor,
modifier,
accountViewModel,
)
} else {
val authorState by observeNote(baseNote, accountViewModel)
CrossfadeIfEnabled(authorState.note.author, accountViewModel = accountViewModel) { author ->
if (author != null) {
BannerImage(
author,
modifier,
accountViewModel,
)
} else {
BannerImage(
null as String?,
modifier,
accountViewModel,
)
}
}
}
}
@@ -420,3 +420,23 @@ fun VerticalDotsIcon() {
tint = MaterialTheme.colorScheme.placeholderText,
)
}
@Composable
fun OnchainZappedIcon(modifier: Modifier) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = stringRes(R.string.onchain_zap_description),
tint = BitcoinOrange,
modifier = modifier,
)
}
@Composable
fun PendingClockBadge(modifier: Modifier) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = stringRes(R.string.onchain_zap_pending),
tint = BitcoinOrange,
modifier = modifier,
)
}
@@ -154,12 +154,11 @@ fun LoadPublicChatChannel(
accountViewModel: AccountViewModel,
content: @Composable (PublicChatChannel) -> Unit,
) {
val channel =
produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) {
value = accountViewModel.checkGetOrCreatePublicChatChannel(id)
}
val channel by produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) {
value = accountViewModel.checkGetOrCreatePublicChatChannel(id)
}
channel.value?.let { content(it) }
channel?.let { content(it) }
}
@Composable
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayContactList
import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList
import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList
@@ -116,7 +117,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
@@ -164,6 +167,9 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember
import com.vitorpamplona.amethyst.ui.note.types.RenderReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareRelease
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderThread
@@ -209,10 +215,14 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent
import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -252,8 +262,11 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
@@ -864,6 +877,23 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is SoftwareApplicationEvent -> {
RenderSoftwareApplication(baseNote, accountViewModel, nav)
}
is SoftwareAssetEvent -> {
RenderSoftwareAsset(baseNote, accountViewModel, nav)
}
is ReleaseArtifactSetEvent -> {
// kind 30063 is used by both NIP-51 (ReleaseArtifactSet) and NIP-82 (SoftwareRelease).
// The EventFactory always materializes the NIP-51 class; dispatch to NIP-82 when the
// tag signature matches.
if (noteEvent.isNip82SoftwareRelease()) {
RenderSoftwareRelease(baseNote, accountViewModel, nav)
}
}
is AttestationEvent -> {
RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
@@ -970,6 +1000,10 @@ private fun RenderNoteRow(
DisplayFollowList(baseNote, true, accountViewModel, nav)
}
is ContactListEvent -> {
DisplayContactList(baseNote, accountViewModel, nav)
}
is RelaySetEvent -> {
DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav)
}
@@ -1189,6 +1223,14 @@ private fun RenderNoteRow(
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
}
is CalendarEvent -> {
RenderCalendarCollectionEvent(baseNote, accountViewModel, nav)
}
is CalendarRSVPEvent -> {
RenderCalendarRSVPEvent(baseNote, accountViewModel, nav)
}
is GoalEvent -> {
RenderGoal(baseNote, accountViewModel, nav)
}
@@ -24,6 +24,7 @@ import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
@@ -136,15 +137,19 @@ fun LongPressToQuickAction(
) {
val popupExpanded = remember { mutableStateOf(false) }
content { popupExpanded.value = true }
// Box anchors the Popup to the note card; otherwise its parent resolves to the
// enclosing LazyColumn and `Alignment.Center` centers on the whole list.
Box {
content { popupExpanded.value = true }
if (popupExpanded.value) {
NoteQuickActionMenu(
note = baseNote,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = nav,
)
if (popupExpanded.value) {
NoteQuickActionMenu(
note = baseNote,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -0,0 +1,220 @@
/*
* 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.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.OnchainZapEntry
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
private fun onOnchainZapEntryClick(
entry: OnchainZapEntry,
nav: INav,
) {
entry.source.author?.let { nav.nav(routeFor(it)) }
}
@Composable
internal fun WatchOnchainZapsAndRenderGallery(
baseNote: Note,
nav: INav,
accountViewModel: AccountViewModel,
) {
// Reuse the same flow the lightning gallery subscribes to. Note.addOnchainZap
// invalidates flowSet.zaps, so this composable refreshes when on-chain zaps
// arrive or upgrade pending → confirmed. The flow ALSO fires for lightning
// zap arrivals on the same note — memoize on the onchainZaps map reference
// (a fresh immutable map per onchain mutation, stable across lightning-only
// updates) so a busy lightning thread doesn't churn this gallery's state.
val zapsState by observeNoteZaps(baseNote, accountViewModel)
val onchainZapsMap = zapsState?.note?.onchainZaps
val entries =
remember(onchainZapsMap) {
onchainZapsMap?.values?.toImmutableList() ?: persistentListOf()
}
if (entries.isNotEmpty()) {
// Drive re-verification of any non-CONFIRMED entries while this gallery
// is on screen — covers home feed, profile, notifications, channels,
// single-note view, threads. Per-note in-flight gating inside the cache
// dedupes the work when multiple gallery instances for the same note
// (lazy-list off/on screen flicker, split feed) all fire together.
DriveOnchainZapReverification(baseNote, entries)
RenderOnchainZapGallery(entries, nav, accountViewModel)
}
}
/**
* Drives on-chain zap re-verification for [note] while the gallery is composed.
*
* Three triggers fire reverify:
* 1. First view of this note (independent of tip availability — covers the cold-
* start case where the chain backend isn't wired yet, or `tipHeight()` is slow).
* 2. A new pending entry arrives (`entries.size` changes).
* 3. The chain tip advances (the shared StateFlow emits a new value).
*
* The cache's per-note + per-event gates dedupe concurrent calls; this composable
* doesn't need its own throttling.
*/
@Composable
private fun DriveOnchainZapReverification(
note: Note,
entries: ImmutableList<OnchainZapEntry>,
) {
val pendingCount = remember(entries) { entries.count { it.status != OnchainZapStatus.CONFIRMED } }
if (pendingCount == 0) return
val resolver = LocalCache.onchainZapResolver
// First-view kick — unconditional, doesn't wait for the tip flow. Keyed on
// (idHex, pendingCount) so a brand-new pending entry arriving while the
// gallery is still on screen also kicks an immediate reverify instead of
// waiting up to a full tip-poll interval.
LaunchedEffect(note.idHex, pendingCount) {
resolver.reverifyOnchainZapsForNote(note)
}
// Tip-change kick — subscribes to the shared poller (lazy, WhileSubscribed
// so only one HTTP poller runs across the whole UI no matter how many
// galleries are visible). Skips the first emission (null) to avoid
// duplicating the first-view kick above.
val tip by resolver.onchainTipHeightFlow.collectAsStateWithLifecycle()
LaunchedEffect(note.idHex, tip) {
if (tip != null) {
resolver.reverifyOnchainZapsForNote(note)
}
}
}
@Composable
private fun RenderOnchainZapGallery(
entries: ImmutableList<OnchainZapEntry>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Row(Modifier.fillMaxWidth()) {
Box(modifier = WidthAuthorPictureModifier) {
OnchainZappedIcon(
modifier = Modifier.size(Size25dp).align(Alignment.TopEnd),
)
}
OnchainZapAuthorGallery(entries, nav, accountViewModel)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun OnchainZapAuthorGallery(
entries: ImmutableList<OnchainZapEntry>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Column(modifier = StdStartPadding) {
FlowRow {
entries.forEach { entry ->
OnchainZapEntryRow(entry, nav, accountViewModel)
}
}
}
}
@Composable
private fun OnchainZapEntryRow(
entry: OnchainZapEntry,
nav: INav,
accountViewModel: AccountViewModel,
) {
val user = entry.source.author
val isConfirmed = entry.status == OnchainZapStatus.CONFIRMED
val avatarAlpha = if (isConfirmed) 1f else 0.6f
// Anti-spoof: `claimedSats` comes from the kind:8333 `amount` tag, which is
// attacker-controlled for incoming zaps. Only show the claimed amount for the
// signed-in user's own outgoing zap (where the user knows what they sent) —
// otherwise show the on-chain verified amount, or nothing while still unverified.
val displaySats =
when {
isConfirmed || entry.status == OnchainZapStatus.PENDING -> entry.verifiedSats
user != null && accountViewModel.isLoggedUser(user.pubkeyHex) -> entry.claimedSats
else -> 0L
}
val amountText =
remember(displaySats) {
if (displaySats > 0L) showAmount(BigDecimal.valueOf(displaySats)) else ""
}
Box(
modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
contentAlignment = Alignment.BottomCenter,
) {
// Only the avatar dims for unverified/pending entries. The amount overlay
// and clock badge stay at full opacity so they remain readable.
Box(modifier = Modifier.alpha(avatarAlpha)) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
user,
accountViewModel,
)
}
if (amountText.isNotEmpty()) {
CrossfadeToDisplayAmount(amountText)
}
if (!isConfirmed) {
// TopStart so the badge doesn't collide with the FollowingIcon
// that WatchUserMetadataAndFollowsAndRenderUserProfilePicture
// paints at TopEnd for followed users.
Box(modifier = Modifier.align(Alignment.TopStart)) {
PendingClockBadge(modifier = Modifier.size(14.dp))
}
}
}
}
@@ -51,6 +51,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
@@ -59,6 +60,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
@@ -83,11 +85,11 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -97,7 +99,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.R
@@ -109,6 +110,8 @@ import com.vitorpamplona.amethyst.model.ReactionRowAction
import com.vitorpamplona.amethyst.model.ReactionRowItem
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReferences
@@ -126,17 +129,16 @@ import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
@@ -171,6 +173,7 @@ import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -348,15 +351,9 @@ fun PayReaction(
) {
val authorPubkey = baseNote.author?.pubkeyHex ?: return
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
LoadAddressableNote(address, accountViewModel) { note ->
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
var expanded by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
ClickableBox(
modifier = iconSizeModifier,
@@ -370,58 +367,12 @@ fun PayReaction(
)
}
if (expanded) {
M3ActionDialog(
title = stringRes(R.string.payment_targets),
onDismiss = { expanded = false },
) {
M3ActionSection {
if (targets.isEmpty()) {
M3ActionRow(
icon = MaterialSymbols.AccountBalanceWallet,
text = stringRes(R.string.no_payment_targets_message),
enabled = false,
onClick = {},
)
} else {
targets.forEach { target ->
M3ActionRow(
icon = MaterialSymbols.AccountBalanceWallet,
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
onClick = {
expanded = false
try {
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
errorMessage = stringRes(context, R.string.no_payment_app_found)
}
},
)
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_to_clipboard),
onClick = {
expanded = false
scope.launch {
clipboardManager.setText(target.authority)
}
},
)
}
}
}
}
}
if (expanded && note != null) {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
val event by observeNoteEvent<PaymentTargetsEvent>(note, accountViewModel)
val targets = remember(event) { event?.paymentTargets() ?: emptyList() }
errorMessage?.let { msg ->
ErrorMessageDialog(
title = stringRes(R.string.error_dialog_payment_error),
textContent = msg,
onDismiss = { errorMessage = null },
)
PaymentTargetsDialog(targets = targets, onDismiss = { expanded = false })
}
}
}
@@ -453,9 +404,14 @@ private fun GenericInnerReactionRow(
}
enabledReactions.forEachIndexed { index, item ->
val isLast = index == lastIndex
// Skip the weighted slice for the rightmost item only when it has no counter
// (e.g. Share / Pay) — those are icon-only, so letting them collapse to natural
// width pins them flush against the right padding. If the rightmost item carries
// a counter (Zap / Like / etc.), keep it weighted so its icon stays at the left
// of an equal-width slice and the counter doesn't get pushed to the edge.
val isLastIconOnly = index == lastIndex && !item.showCounter
val itemWeight = if (item.action == ReactionRowAction.Reply) weightTwo else 1f
val mod = if (isLast) Modifier else Modifier.weight(itemWeight)
val mod = if (isLastIconOnly) Modifier else Modifier.weight(itemWeight)
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = RowColSpacing,
@@ -597,6 +553,7 @@ private fun ReactionDetailGallery(
) {
Column {
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
}
@@ -1171,6 +1128,11 @@ private fun likeClick(
}
}
@Immutable
private data class OnchainZapRequest(
val amountSats: Long?,
)
@Composable
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
fun ZapReaction(
@@ -1185,6 +1147,9 @@ fun ZapReaction(
) {
var wantsToZap by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
// null = closed; OnchainZapRequest(amount=null) = open with no prefill;
// OnchainZapRequest(amount=N) = open prefilled to N sats.
var onchainZapRequest by remember { mutableStateOf<OnchainZapRequest?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -1257,6 +1222,10 @@ fun ZapReaction(
nav.nav(Route.UpdateZapAmount())
}
},
onOnchainAmount = { amount ->
wantsToZap = false
onchainZapRequest = OnchainZapRequest(amount)
},
onError = { _, message, user ->
scope.launch {
zappingProgress = 0f
@@ -1280,6 +1249,17 @@ fun ZapReaction(
)
}
onchainZapRequest?.let { request ->
val zappedEventHint = remember(baseNote) { baseNote.toEventHint<Event>() }
OnchainZapSendDialog(
accountViewModel = accountViewModel,
onDismiss = { onchainZapRequest = null },
recipientPubKey = baseNote.author?.pubkeyHex,
zappedEvent = zappedEventHint,
prefillAmountSats = request.amountSats,
)
}
if (wantsToSetCustomZap) {
ZapCustomDialog(
onZapStarts = { zapStartingTime = TimeUtils.now() },
@@ -1884,12 +1864,29 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onOnchainAmount: ((Long?) -> Unit)? = null,
) {
val zapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
val onchainZapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
.collectAsStateWithLifecycle()
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onZapStarts, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent)
ZapAmountChoicePopup(
baseNote = baseNote,
zapAmountChoices = zapAmountChoices,
accountViewModel = accountViewModel,
popupYOffset = popupYOffset,
onZapStarts = onZapStarts,
onDismiss = onDismiss,
onChangeAmount = onChangeAmount,
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
onchainZapAmountChoices = if (onOnchainAmount != null) onchainZapAmountChoices else persistentListOf(),
onOnchainAmount = onOnchainAmount ?: {},
)
}
@Composable
@@ -1904,9 +1901,11 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
) {
val visibilityState = rememberVisibilityState(onDismiss)
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onError, onProgress, onPayViaIntent)
ZapAmountChoicePopup(baseNote, zapAmountChoices, onchainZapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onOnchainAmount, onError, onProgress, onPayViaIntent)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@@ -1914,18 +1913,18 @@ fun ZapAmountChoicePopup(
fun ZapAmountChoicePopup(
baseNote: Note,
zapAmountChoices: ImmutableList<Long>,
onchainZapAmountChoices: ImmutableList<Long>,
accountViewModel: AccountViewModel,
popupYOffset: Dp,
visibilityState: MutableTransitionState<Boolean>,
onZapStarts: () -> Unit,
onChangeAmount: () -> Unit,
onOnchainAmount: (Long?) -> Unit,
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
val context = LocalContext.current
val zapMessage = ""
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
Popup(
@@ -1939,62 +1938,175 @@ fun ZapAmountChoicePopup(
enter = popupAnimationEnter,
exit = popupAnimationExit,
) {
FlowRow(horizontalArrangement = Arrangement.Center) {
ZapAmountChoicePopupContent(
zapAmountChoices = zapAmountChoices,
onchainZapAmountChoices = onchainZapAmountChoices,
onZap = { amountInSats ->
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
"",
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
onChangeAmount = onChangeAmount,
onOnchainAmount = { amount ->
onOnchainAmount(amount)
visibilityState.targetState = false
},
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ZapAmountChoicePopupContent(
zapAmountChoices: ImmutableList<Long>,
onZap: (Long) -> Unit,
onChangeAmount: () -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard(
shape = SmallBorder,
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
FlowRow(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
itemVerticalAlignment = CenterVertically,
) {
zapAmountChoices.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(
"⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier =
Modifier.combinedClickable(
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
onLongClick = { onChangeAmount() },
),
)
}
ZapAmountChip(
amountInSats = amountInSats,
onClick = { onZap(amountInSats) },
onLongClick = onChangeAmount,
)
}
onchainZapAmountChoices.forEach { amountInSats ->
OnchainZapAmountChip(
amountInSats = amountInSats,
onClick = { onOnchainAmount(amountInSats) },
onLongClick = onChangeAmount,
)
}
ClickableBox(
modifier =
Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
.size(32.dp)
.padding(7.dp),
onClick = onChangeAmount,
) {
Icon(
symbol = MaterialSymbols.Tune,
contentDescription = stringRes(R.string.quick_zap_amounts),
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ZapAmountChip(
amountInSats: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = BitcoinOrange,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Size18Modifier,
tint = Color.White,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = Color.White,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun OnchainZapAmountChip(
amountInSats: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = BitcoinOrange,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = null,
modifier = Size18Modifier,
tint = Color.White,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = Color.White,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
}
}
@Preview
@Composable
fun ZapAmountChoicePopupPreview() {
ThemeComparisonColumn {
ZapAmountChoicePopupContent(
zapAmountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L),
onchainZapAmountChoices = persistentListOf(10_000L, 50_000L, 250_000L),
onZap = {},
onChangeAmount = {},
onOnchainAmount = {},
)
}
}
fun showCount(count: Int?): String {
if (count == null) return ""
if (count == 0) return ""
@@ -284,7 +284,7 @@ fun UpdateZapAmountContent(
),
placeholder = {
Text(
text = "100, 1000, 5000",
text = "21, 50, 100",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -339,7 +339,96 @@ fun UpdateZapAmountContent(
)
}
// ── Section 3: Nostr Wallet Connect ───────────────────────────────────
// ── Section 3: Quick On-chain Zap Amounts ─────────────────────────────
Text(
text = stringRes(R.string.quick_zap_amounts_onchain),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
modifier = SettingsCategorySpacingModifier,
)
Text(
text = stringRes(R.string.quick_zap_amounts_onchain_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(bottom = 6.dp),
)
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
postViewModel.onchainAmountSet.forEach { amountInSats ->
InputChip(
selected = false,
onClick = { postViewModel.removeOnchainAmount(amountInSats) },
label = {
Text(
text = "₿ ${showAmount(amountInSats.toBigDecimal().setScale(1))}",
)
},
trailingIcon = {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.remove),
modifier = Modifier.size(InputChipDefaults.AvatarSize),
)
},
colors =
InputChipDefaults.inputChipColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.primary,
trailingIconColor = MaterialTheme.colorScheme.primary,
),
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.new_amount_in_sats_onchain)) },
value = postViewModel.nextOnchainAmount,
onValueChange = { postViewModel.nextOnchainAmount = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number,
),
placeholder = {
Text(
text = "5000, 25000, 100000",
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
IconButton(
onClick = postViewModel::addOnchainAmount,
shape = ButtonBorder,
enabled = postViewModel.nextOnchainAmount.text.isNotBlank(),
) {
Icon(
symbol = MaterialSymbols.AddCircle,
contentDescription = stringRes(R.string.add),
modifier = Size20Modifier,
)
}
},
singleLine = true,
modifier = Modifier.weight(1f),
)
}
// ── Section 4: Nostr Wallet Connect ───────────────────────────────────
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
@@ -41,6 +41,8 @@ class UpdateZapAmountViewModel : ViewModel() {
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
var nextOnchainAmount by mutableStateOf(TextFieldValue(""))
var onchainAmountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
@@ -59,6 +61,7 @@ class UpdateZapAmountViewModel : ViewModel() {
fun load() {
this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value
this.onchainAmountSet = accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value
this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value
val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest()
@@ -83,6 +86,19 @@ class UpdateZapAmountViewModel : ViewModel() {
amountSet = amountSet - amount
}
fun addOnchainAmount() {
val newValue = nextOnchainAmount.text.trim().toLongOrNull()
if (newValue != null) {
onchainAmountSet = onchainAmountSet + newValue
}
nextOnchainAmount = TextFieldValue("")
}
fun removeOnchainAmount(amount: Long) {
onchainAmountSet = onchainAmountSet - amount
}
fun sendPost() {
accountViewModel.launchSigner {
sendPostSuspend()
@@ -116,13 +132,15 @@ class UpdateZapAmountViewModel : ViewModel() {
null
}
accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
accountViewModel.account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update)
nextAmount = TextFieldValue("")
nextOnchainAmount = TextFieldValue("")
}
fun cancel() {
nextAmount = TextFieldValue("")
nextOnchainAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
@@ -130,6 +148,7 @@ class UpdateZapAmountViewModel : ViewModel() {
return (
selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value ||
amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value ||
onchainAmountSet != accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value ||
walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") ||
walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") ||
walletConnectSecret.text != (defaultUri?.secret ?: "")
@@ -48,6 +48,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -89,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
@@ -157,6 +160,11 @@ fun ZapCustomDialog(
val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() }
// True while the on-chain hand-off sheet is open. Dismissing the on-chain
// sheet closes the whole zap flow — the user has already committed to a
// payment method, no "go back to Lightning" is offered here.
var sendOnchain by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = { onClose() },
properties =
@@ -228,7 +236,7 @@ fun ZapCustomDialog(
),
placeholder = {
Text(
text = "100, 1000, 5000",
text = "21, 50, 100",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -326,9 +334,39 @@ fun ZapCustomDialog(
)
onClose()
}
// Hand off to the on-chain dialog with the entered amount +
// message prefilled. Disabled while the amount is empty so the
// user can't open a sheet that immediately gates on its own
// empty field.
TextButton(
onClick = { sendOnchain = true },
enabled = postViewModel.canSend() && !baseNote.isDraft(),
modifier =
Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Text(text = stringRes(id = R.string.send_onchain_instead))
}
}
}
}
if (sendOnchain) {
val zappedEventHint = remember(baseNote) { baseNote.toEventHint<Event>() }
OnchainZapSendDialog(
accountViewModel = accountViewModel,
onDismiss = {
sendOnchain = false
onClose()
},
recipientPubKey = baseNote.author?.pubkeyHex,
zappedEvent = zappedEventHint,
prefillAmountSats = postViewModel.value(),
prefillComment = postViewModel.customMessage.text,
)
}
}
@Composable
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import android.content.Context
import android.location.Location
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,7 +32,10 @@ import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import com.vitorpamplona.amethyst.service.location.toGeoHash
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
private const val MAX_GEOLOCATE_RETRIES = 5
@Composable
fun LoadCityName(
@@ -40,34 +45,50 @@ fun LoadCityName(
) {
var cityName by remember(geohashStr) { mutableStateOf(CachedReversedGeoLocations.cached(geohashStr)) }
if (cityName == null) {
if (onLoading != null) {
onLoading()
}
val resolved = cityName
if (resolved != null) {
content(resolved)
} else if (!CachedReversedGeoLocations.isGeocoderAvailable()) {
// Devices without a system Geocoder backend (e.g. AOSP / GrapheneOS
// without Google Play Services or microG) cannot reverse-geocode at
// all. Show the geohash directly instead of spinning forever.
content(geohashStr)
} else {
onLoading?.invoke()
val context = LocalContext.current
LaunchedEffect(key1 = geohashStr, context) {
LaunchedEffect(geohashStr, context) {
val location = runCatching { geohashStr.toGeoHash() }.getOrNull()?.toLocation()
if (location != null) {
launch {
var notReady = true
var myStep = 1000L
while (notReady) {
// Retries while the Reverse Geolocation service is offline.
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { newCityName ->
if (newCityName != cityName) {
notReady = false
cityName = newCityName
}
}
myStep = myStep * 2L
delay(myStep)
}
}
if (location == null) {
cityName = geohashStr
return@LaunchedEffect
}
var delayMs = 1000L
repeat(MAX_GEOLOCATE_RETRIES) {
val result = geoLocateOnce(geohashStr, location, context)
if (result != null) {
cityName = result
return@LaunchedEffect
}
delay(delayMs)
delayMs *= 2L
}
// Geocoder kept returning errors — give up and render the geohash
// so the loading indicator stops.
cityName = geohashStr
}
} else {
cityName?.let { content(it) }
}
}
private suspend fun geoLocateOnce(
geohashStr: String,
location: Location,
context: Context,
): String? =
suspendCancellableCoroutine { cont ->
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { result ->
if (cont.isActive) cont.resume(result)
}
}
@@ -29,7 +29,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -41,7 +40,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -123,9 +124,11 @@ fun ForwardZapTo(
}
}
OutlinedTextField(
value = postViewModel.forwardZapToEditting.value,
onValueChange = { postViewModel.updateZapForwardTo(it) },
OutlinedThinPaddingTextField(
state = postViewModel.forwardZapToEditting,
onTextChanged = postViewModel::onForwardZapTextChanged,
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
label = { Text(text = stringRes(R.string.zap_split_search_and_add_user)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
@@ -135,10 +138,6 @@ fun ForwardZapTo(
)
},
singleLine = true,
visualTransformation =
UrlUserTagTransformation(
MaterialTheme.colorScheme.primary,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -20,15 +20,15 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zapsplits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.model.User
@Stable
interface IZapField {
val forwardZapTo: MutableState<SplitBuilder<User>>
val forwardZapToEditting: MutableState<TextFieldValue>
val forwardZapToEditting: TextFieldState
fun updateZapFromText()
@@ -37,5 +37,5 @@ interface IZapField {
sliderValue: Float,
)
fun updateZapForwardTo(newZapForwardTo: TextFieldValue)
fun onForwardZapTextChanged()
}
@@ -119,38 +119,32 @@ fun BannerImage(
if (!banner.isNullOrBlank()) {
MyAsyncImage(
imageUrl = banner,
contentDescription =
stringRes(
R.string.preview_card_image_for,
banner,
),
contentDescription = stringRes(R.string.preview_card_image_for, banner),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier,
loadedImageModifier = modifier,
accountViewModel = accountViewModel,
onLoadingBackground = {
Image(
painter = painterRes(R.drawable.profile_banner, 4),
contentDescription = stringRes(R.string.profile_banner),
contentScale = ContentScale.Crop,
modifier = modifier,
)
DefaultProfileBanner(modifier, 4)
},
onError = {
Image(
painter = painterRes(R.drawable.profile_banner, 4),
contentDescription = stringRes(R.string.profile_banner),
contentScale = ContentScale.Crop,
modifier = modifier,
)
DefaultProfileBanner(modifier, 4)
},
)
} else {
Image(
painter = painterRes(R.drawable.profile_banner, 5),
contentDescription = stringRes(R.string.profile_banner),
contentScale = ContentScale.Crop,
modifier = modifier,
)
DefaultProfileBanner(modifier, 5)
}
}
@Composable
fun DefaultProfileBanner(
modifier: Modifier,
sizeReference: Int,
) {
Image(
painter = painterRes(R.drawable.profile_banner, sizeReference),
contentDescription = stringRes(R.string.profile_banner),
contentScale = ContentScale.Crop,
modifier = modifier,
)
}
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
@@ -194,7 +194,7 @@ open class CommentPostViewModel :
// Forward Zap to
var wantsForwardZapTo by mutableStateOf(false)
override var forwardZapTo = mutableStateOf<SplitBuilder<User>>(SplitBuilder())
override var forwardZapToEditting = mutableStateOf(TextFieldValue(""))
override val forwardZapToEditting = TextFieldState()
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
@@ -389,7 +389,7 @@ open class CommentPostViewModel :
val value = it.last().toFloatOrNull() ?: 0f
forwardZapTo.value.addItem(user, value)
}
forwardZapToEditting.value = TextFieldValue("")
forwardZapToEditting.clearText()
wantsForwardZapTo = localForwardZapTo.isNotEmpty()
wantsToMarkAsSensitive = draftEvent.isSensitive()
@@ -713,7 +713,7 @@ open class CommentPostViewModel :
wantsAnonymousPost = false
forwardZapTo.value = SplitBuilder()
forwardZapToEditting.value = TextFieldValue("")
forwardZapToEditting.clearText()
urlPreviews.reset()
@@ -753,10 +753,9 @@ open class CommentPostViewModel :
draftTag.newVersion()
}
override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) {
forwardZapToEditting.value = newZapForwardTo
if (newZapForwardTo.selection.collapsed) {
val lastWord = newZapForwardTo.text
override fun onForwardZapTextChanged() {
if (forwardZapToEditting.selection.collapsed) {
val lastWord = forwardZapToEditting.text.toString()
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
userSuggestions?.processCurrentWord(lastWord)
}
@@ -770,7 +769,7 @@ open class CommentPostViewModel :
urlPreviews.update(message.text.toString())
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
forwardZapTo.value.addItem(item)
forwardZapToEditting.value = TextFieldValue("")
forwardZapToEditting.clearText()
}
userSuggestionsMainMessage = null
@@ -0,0 +1,98 @@
/*
* 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.note.types
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@Composable
fun RenderCalendarCollectionEvent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarEvent ?: return
Column(MaterialTheme.colorScheme.replyModifier) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = event.title() ?: "—",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (event.content.isNotBlank()) {
Spacer(modifier = StdVertSpacer)
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp),
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = StdVertSpacer)
val eventCount = event.calendarEventAddresses().size
Text(
text = pluralStringResource(R.plurals.calendar_collection_count, eventCount, eventCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
)
}
}
@@ -86,6 +86,15 @@ fun RenderCalendarTimeSlotEvent(
dateRange = dateRange,
note = note,
accountViewModel = accountViewModel,
rsvpRow = {
CalendarRsvpRow(
eventKind = CalendarTimeSlotEvent.KIND,
eventPubKey = noteEvent.pubKey,
eventDTag = noteEvent.dTag(),
eventId = noteEvent.id,
accountViewModel = accountViewModel,
)
},
)
}
@@ -119,6 +128,15 @@ fun RenderCalendarDateSlotEvent(
dateRange = dateRange,
note = note,
accountViewModel = accountViewModel,
rsvpRow = {
CalendarRsvpRow(
eventKind = CalendarDateSlotEvent.KIND,
eventPubKey = noteEvent.pubKey,
eventDTag = noteEvent.dTag(),
eventId = noteEvent.id,
accountViewModel = accountViewModel,
)
},
)
}
@@ -131,6 +149,7 @@ private fun CalendarHeader(
dateRange: String?,
note: Note,
accountViewModel: AccountViewModel,
rsvpRow: @Composable () -> Unit = {},
) {
Column(MaterialTheme.colorScheme.replyModifier) {
image?.let {
@@ -209,6 +228,8 @@ private fun CalendarHeader(
if (summary == null) {
Spacer(modifier = StdVertSpacer)
}
rsvpRow()
}
}
@@ -0,0 +1,120 @@
/*
* 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.note.types
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
@Composable
fun RenderCalendarRSVPEvent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? CalendarRSVPEvent ?: return
val status = event.status()
val targetAddress = event.calendarEventAddress()
val freebusy = event.freebusy()
val statusLabel =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> stringRes(R.string.calendar_rsvp_going)
RSVPStatusTag.STATUS.TENTATIVE -> stringRes(R.string.calendar_rsvp_maybe)
RSVPStatusTag.STATUS.DECLINED -> stringRes(R.string.calendar_rsvp_not_going)
null -> "—"
}
val statusColor =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
null -> Color.Gray
}
Column(MaterialTheme.colorScheme.replyModifier) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = statusColor,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = statusLabel,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = statusColor,
)
}
if (event.content.isNotBlank()) {
Spacer(modifier = StdVertSpacer)
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, end = 10.dp),
)
}
targetAddress?.let { addr ->
Spacer(modifier = StdVertSpacer)
Text(
text = "→ ${addr.toValue()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
)
}
if (freebusy != null) {
Spacer(modifier = StdVertSpacer)
}
}
}
@@ -0,0 +1,187 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* Renders a 3-button RSVP row (Going / Maybe / Can't go) below a NIP-52 calendar appointment.
*
* Uses a deterministic d-tag derived from the target appointment's address so the user has
* exactly one RSVP per event from this client — tapping again replaces it rather than appending
* another addressable. The button matching the current status renders as filled-tonal; the
* others render outlined.
*/
@Composable
fun CalendarRsvpRow(
eventKind: Int,
eventPubKey: String,
eventDTag: String,
eventId: String,
accountViewModel: AccountViewModel,
) {
val myPubKey = accountViewModel.userProfile().pubkeyHex
val targetAddress = remember(eventKind, eventPubKey, eventDTag) { Address(eventKind, eventPubKey, eventDTag) }
val myRsvpAddress = remember(targetAddress, myPubKey) { rsvpAddressFor(myPubKey, targetAddress) }
val myRsvpNote = remember(myRsvpAddress) { LocalCache.getOrCreateAddressableNote(myRsvpAddress) }
val myRsvpState by myRsvpNote
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val currentStatus = (myRsvpState.note.event as? CalendarRSVPEvent)?.status()
val onTap: (RSVPStatusTag.STATUS) -> Unit = { newStatus ->
sendRsvp(
accountViewModel = accountViewModel,
targetAddress = targetAddress,
eventId = eventId,
myPubKey = myPubKey,
status = newStatus,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RsvpButton(
label = stringRes(R.string.calendar_rsvp_going),
status = RSVPStatusTag.STATUS.ACCEPTED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(R.string.calendar_rsvp_maybe),
status = RSVPStatusTag.STATUS.TENTATIVE,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(R.string.calendar_rsvp_not_going),
status = RSVPStatusTag.STATUS.DECLINED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
}
}
@Composable
private fun RsvpButton(
label: String,
status: RSVPStatusTag.STATUS,
currentStatus: RSVPStatusTag.STATUS?,
modifier: Modifier,
onClick: (RSVPStatusTag.STATUS) -> Unit,
) {
val selected = status == currentStatus
if (selected) {
FilledTonalButton(
onClick = { onClick(status) },
modifier = modifier,
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = colorFor(status),
contentColor = Color.White,
),
) {
Text(text = label)
}
} else {
OutlinedButton(
onClick = { onClick(status) },
modifier = modifier,
) {
Text(text = label)
}
}
}
@Composable
private fun colorFor(status: RSVPStatusTag.STATUS) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
}
/**
* Deterministic per-target d-tag so each user's RSVP for a given event is a single addressable.
* The format mirrors the a-tag coordinate so it's debuggable (`rsvp:31923:<pubkey>:<dtag>`).
*/
fun rsvpDTagFor(targetAddress: Address): String = "rsvp:${targetAddress.kind}:${targetAddress.pubKeyHex}:${targetAddress.dTag}"
fun rsvpAddressFor(
myPubKey: String,
targetAddress: Address,
): Address = Address(CalendarRSVPEvent.KIND, myPubKey, rsvpDTagFor(targetAddress))
private fun sendRsvp(
accountViewModel: AccountViewModel,
targetAddress: Address,
eventId: String,
myPubKey: String,
status: RSVPStatusTag.STATUS,
) {
val relayHint = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull()
val aTag = ATag(targetAddress, relayHint)
val pTag = PTag(targetAddress.pubKeyHex)
val dTag = rsvpDTagFor(targetAddress)
accountViewModel.launchSigner {
accountViewModel.account.signAndComputeBroadcast(
CalendarRSVPEvent.build(
calendarEventAddress = aTag,
status = status,
calendarEventAuthor = pTag,
dTag = dTag,
),
)
}
}

Some files were not shown because too many files have changed in this diff Show More