mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge upstream/main into worktree-desktop-low-hanging-fruit
This commit is contained in:
@@ -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:
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
-225
@@ -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))
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
// F-Droid distributes Amethyst as MIT-licensed free software; the build must
|
||||
// not surface links to external (e.g. GitHub-hosted) policy documents.
|
||||
@Composable
|
||||
fun LegalSettingsSection() {
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedOff.legal
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
// F-Droid distributes Amethyst as MIT-licensed free software; there is no
|
||||
// terms-of-use acceptance layered on top of the source license, and the build
|
||||
// must not link to any external (e.g. GitHub) policy document.
|
||||
@Composable
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun TermsGate(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
showError: Boolean,
|
||||
) {
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
|
||||
+13
-1
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-19
@@ -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) {
|
||||
|
||||
+8
-6
@@ -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.
|
||||
|
||||
+295
@@ -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
|
||||
}
|
||||
}
|
||||
+3
@@ -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
|
||||
|
||||
+10
@@ -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>,
|
||||
|
||||
+119
@@ -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()
|
||||
}
|
||||
+65
@@ -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"
|
||||
}
|
||||
}
|
||||
+83
@@ -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
|
||||
}
|
||||
}
|
||||
+140
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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(" ")
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+15
@@ -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(
|
||||
|
||||
+22
-21
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+33
-9
@@ -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
|
||||
|
||||
+47
@@ -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,
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -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 {
|
||||
|
||||
+139
-41
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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(
|
||||
|
||||
+23
-10
@@ -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)
|
||||
}
|
||||
|
||||
+26
@@ -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")
|
||||
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+11
@@ -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() }
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+19
-18
@@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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(
|
||||
|
||||
+10
-11
@@ -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()),
|
||||
)
|
||||
|
||||
+1
-3
@@ -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,
|
||||
)
|
||||
|
||||
+16
-3
@@ -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() }
|
||||
}
|
||||
|
||||
+2
-4
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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),
|
||||
)
|
||||
|
||||
+43
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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()
|
||||
|
||||
-229
@@ -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
|
||||
}
|
||||
+1
@@ -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),
|
||||
|
||||
+177
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+43
-11
@@ -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) }
|
||||
|
||||
+27
@@ -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(
|
||||
|
||||
+3
-3
@@ -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),
|
||||
|
||||
+20
-1
@@ -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
|
||||
|
||||
+45
-24
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-9
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
|
||||
+17
-23
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+8
-9
@@ -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
|
||||
|
||||
+98
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+120
@@ -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
Reference in New Issue
Block a user