mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
286 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0abc4a224 | ||
|
|
ee67ba6408 | ||
|
|
7415b1db39 | ||
|
|
f19a47007e | ||
|
|
55e536dfd0 | ||
|
|
05d958d182 | ||
|
|
a4310317a0 | ||
|
|
40eb0f0b61 | ||
|
|
b04cf3aedc | ||
|
|
924101c3f4 | ||
|
|
0cc435aa02 | ||
|
|
4fba293d60 | ||
|
|
a1cb5cb50d | ||
|
|
15474cfff4 | ||
|
|
8bf1040f18 | ||
|
|
63e6283354 | ||
|
|
d7a1b631b2 | ||
|
|
c7226d3293 | ||
|
|
6ecedb72f5 | ||
|
|
995ec3dab2 | ||
|
|
b7e3ba27ac | ||
|
|
f6db678249 | ||
|
|
b2959dcb75 | ||
|
|
e6b6269314 | ||
|
|
9d0397080f | ||
|
|
85dfe93ea7 | ||
|
|
a90dd47ed4 | ||
|
|
dfd06e0dfd | ||
|
|
45aa6044b7 | ||
|
|
13a599e282 | ||
|
|
3ed2245d8c | ||
|
|
c19f0be57e | ||
|
|
96883dbec1 | ||
|
|
b0b0a13650 | ||
|
|
0f92abd633 | ||
|
|
360dea9de9 | ||
|
|
030c982a01 | ||
|
|
5962f7d659 | ||
|
|
9b6d65c9f1 | ||
|
|
872bb4f245 | ||
|
|
99e98e0213 | ||
|
|
0c2a5720c1 | ||
|
|
e0f29aefb0 | ||
|
|
89b47da42a | ||
|
|
339ad6dae7 | ||
|
|
7232456e9e | ||
|
|
fc8f057e3e | ||
|
|
ec28d4eb4c | ||
|
|
baf043343d | ||
|
|
59fd1f2244 | ||
|
|
ebfd066d5c | ||
|
|
5841ca652c | ||
|
|
fb4dfcefec | ||
|
|
c0a22e5c0b | ||
|
|
a4be1d21fe | ||
|
|
ce9a7da265 | ||
|
|
33e1790348 | ||
|
|
071b88c7eb | ||
|
|
754e03767a | ||
|
|
86a8fb0743 | ||
|
|
7403ccc825 | ||
|
|
6be8f2db9e | ||
|
|
dde539f0fd | ||
|
|
e131b0fec2 | ||
|
|
4f094929d6 | ||
|
|
446345b35e | ||
|
|
d4f7f9f178 | ||
|
|
d4f7f745af | ||
|
|
3a07b1879e | ||
|
|
9099fd4b84 | ||
|
|
6f4978f149 | ||
|
|
ec3be14cf8 | ||
|
|
2ff67479a3 | ||
|
|
6a0801427b | ||
|
|
9b94874d01 | ||
|
|
679c5a89e0 | ||
|
|
297c2adffa | ||
|
|
952aa24001 | ||
|
|
bef3bd61c5 | ||
|
|
97a6d4bcbd | ||
|
|
62da33f02b | ||
|
|
5045ef9dcf | ||
|
|
3a84aa0168 | ||
|
|
1909fe7e4c | ||
|
|
c7a4796b25 | ||
|
|
cc78744a10 | ||
|
|
382729520c | ||
|
|
1b563fb3f0 | ||
|
|
d96209b20d | ||
|
|
638e22c262 | ||
|
|
fc1da467e7 | ||
|
|
5dfabf667f | ||
|
|
0dea41602a | ||
|
|
5116bc21ae | ||
|
|
ab5d884b34 | ||
|
|
971e9e4846 | ||
|
|
eeda5810ae | ||
|
|
3315ccccec | ||
|
|
a5f03b21cc | ||
|
|
993810aa2f | ||
|
|
cc5ae46f5b | ||
|
|
55147b7781 | ||
|
|
8d35a73416 | ||
|
|
18512e11ff | ||
|
|
390d750638 | ||
|
|
bf380ec015 | ||
|
|
e901364e87 | ||
|
|
447301599c | ||
|
|
87f9a346cc | ||
|
|
802f89723e | ||
|
|
809f21252c | ||
|
|
5343c7c636 | ||
|
|
b5d5cbed03 | ||
|
|
fcab611bc4 | ||
|
|
2326738b84 | ||
|
|
333a3f92ed | ||
|
|
6e37133b27 | ||
|
|
6c4ff97cf1 | ||
|
|
53d4a7ecae | ||
|
|
77a9bc8754 | ||
|
|
b82ad10ca5 | ||
|
|
a568cbbc74 | ||
|
|
d42482ff56 | ||
|
|
40995b9670 | ||
|
|
a8b6766f49 | ||
|
|
76c4771b58 | ||
|
|
1f0eab5f1d | ||
|
|
bcb6f131dd | ||
|
|
24cf3fdc5f | ||
|
|
3f458f8316 | ||
|
|
d19bcf07b0 | ||
|
|
c75c2013fb | ||
|
|
f3637dd7c1 | ||
|
|
36e285f684 | ||
|
|
8d4089e7e9 | ||
|
|
df87557ef3 | ||
|
|
49b244f026 | ||
|
|
8b3ed48fb6 | ||
|
|
ed686b71ed | ||
|
|
37ea5dea01 | ||
|
|
100533dfd7 | ||
|
|
88fd15eefc | ||
|
|
872533f102 | ||
|
|
0c2eecf62c | ||
|
|
4346427e9f | ||
|
|
cb6e1fe696 | ||
|
|
1afa86cb26 | ||
|
|
facdd3dc8f | ||
|
|
439331722a | ||
|
|
7b720a9a8d | ||
|
|
e94e1782c1 | ||
|
|
a747e20c86 | ||
|
|
f2f02ab5cb | ||
|
|
61a282ba28 | ||
|
|
5973429860 | ||
|
|
e1e185a6f2 | ||
|
|
de222d152b | ||
|
|
8c3868bc32 | ||
|
|
cf44c092dd | ||
|
|
91ded74636 | ||
|
|
d41cf6d75b | ||
|
|
5ad21b6acd | ||
|
|
7c3399438d | ||
|
|
733e2945bd | ||
|
|
487dd3f2ac | ||
|
|
c0c055e771 | ||
|
|
d0a6bbc96f | ||
|
|
2010e41b25 | ||
|
|
cca9c2ff3a | ||
|
|
cf6541a1e1 | ||
|
|
7dce3a420f | ||
|
|
020d5195b5 | ||
|
|
4e796c8e8b | ||
|
|
19c750dede | ||
|
|
2a9514d657 | ||
|
|
e977b96ee6 | ||
|
|
5da20a9b44 | ||
|
|
ab3d1dd7e5 | ||
|
|
a42a156f13 | ||
|
|
cfa19ce7da | ||
|
|
0d91245e02 | ||
|
|
4d428025d3 | ||
|
|
b16d44721c | ||
|
|
acf3daaf75 | ||
|
|
b71b7cb420 | ||
|
|
8b31e60f52 | ||
|
|
a2da9ae2c6 | ||
|
|
fdd954c2ed | ||
|
|
37f6f92c04 | ||
|
|
68c081602f | ||
|
|
09857b8924 | ||
|
|
af4835aff3 | ||
|
|
b2b4895570 | ||
|
|
b4ef70176e | ||
|
|
4d88444a59 | ||
|
|
d3c9f5a4f0 | ||
|
|
54a6109d83 | ||
|
|
2fff2c137c | ||
|
|
6be26e1412 | ||
|
|
48d9e80e20 | ||
|
|
90272fea6f | ||
|
|
669199ab7e | ||
|
|
a3b2d270c7 | ||
|
|
1c5230cfc5 | ||
|
|
34cb4eb2ad | ||
|
|
012dae31d0 | ||
|
|
67e2ee6bd7 | ||
|
|
994923fb84 | ||
|
|
869447cfb1 | ||
|
|
f57facab06 | ||
|
|
ee18490761 | ||
|
|
fcf704a3c6 | ||
|
|
5cd9d3d873 | ||
|
|
67fc5608cc | ||
|
|
6c71eec2fa | ||
|
|
b51de1ae19 | ||
|
|
de5dc34cb7 | ||
|
|
935ad84ac3 | ||
|
|
543c4dfeb8 | ||
|
|
34f5dcac69 | ||
|
|
69190e7810 | ||
|
|
1f47a63470 | ||
|
|
447c662dff | ||
|
|
cc83c24427 | ||
|
|
da2145459e | ||
|
|
51e136abdd | ||
|
|
961c649901 | ||
|
|
e719d9ef00 | ||
|
|
27488f8f34 | ||
|
|
358bd6a325 | ||
|
|
9390c25b2c | ||
|
|
7b6293b945 | ||
|
|
4044c9341c | ||
|
|
92a25e3a38 | ||
|
|
53c6d5d14d | ||
|
|
968a396779 | ||
|
|
0cf8ecef13 | ||
|
|
57acfe200a | ||
|
|
55802fa21b | ||
|
|
09c4d70048 | ||
|
|
92b1930228 | ||
|
|
e5cf575602 | ||
|
|
8ffe1aee4f | ||
|
|
44234aa648 | ||
|
|
7e750d2517 | ||
|
|
4a23ecf7ef | ||
|
|
ca9f7233f2 | ||
|
|
a1e37109c3 | ||
|
|
280f21159f | ||
|
|
637bb4aba5 | ||
|
|
318e4250de | ||
|
|
463c61c61e | ||
|
|
b96f1b5847 | ||
|
|
627b75681f | ||
|
|
e4b8e7ccc8 | ||
|
|
a011421ff5 | ||
|
|
5aedcd0a19 | ||
|
|
3f12bdc2d7 | ||
|
|
1d5ce994a6 | ||
|
|
e6a5ba9e15 | ||
|
|
6361fb1c9d | ||
|
|
fb2f05d9cb | ||
|
|
f1d02f3c60 | ||
|
|
8a5d0019c1 | ||
|
|
05467582b4 | ||
|
|
f901db699d | ||
|
|
19309c4587 | ||
|
|
7ed93bcbc2 | ||
|
|
e885192f51 | ||
|
|
523b0616cf | ||
|
|
63ddb5159f | ||
|
|
25a49d1050 | ||
|
|
d77f2f7333 | ||
|
|
07724e410b | ||
|
|
db6254872e | ||
|
|
1d7be1d444 | ||
|
|
5f16ebc060 | ||
|
|
bb0f41d320 | ||
|
|
40ccffee35 | ||
|
|
f5e7240425 | ||
|
|
a42b1e0f32 | ||
|
|
bb5613ca8b | ||
|
|
6ce193d46a | ||
|
|
031159b3f6 | ||
|
|
79f1d43581 | ||
|
|
d095dc790d |
@@ -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 = 444
|
||||
versionName = "1.09.2"
|
||||
versionCode = 447
|
||||
versionName = "1.11.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -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.09.2` (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.09.2"
|
||||
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.09.2")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current version
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.09.2
|
||||
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.09.2"
|
||||
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.09.2")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.11.0")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
|
||||
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.09.2
|
||||
com.vitorpamplona.quartz:quartz:1.11.0
|
||||
```
|
||||
|
||||
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
||||
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
26
.github/workflows/create-release.yml
vendored
26
.github/workflows/create-release.yml
vendored
@@ -21,9 +21,13 @@ permissions:
|
||||
env:
|
||||
# Asset naming contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
|
||||
# Single source of truth in scripts/asset-name.sh.
|
||||
# linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below.
|
||||
LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
|
||||
LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0
|
||||
# appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below.
|
||||
# We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to
|
||||
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so RPATH
|
||||
# mismatch) and the UPX-compressed VLC plugins. appimagetool only embeds the
|
||||
# AppDir as-is, which is what we actually want.
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -35,7 +39,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
@@ -84,17 +87,19 @@ jobs:
|
||||
if: matrix.family == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
|
||||
|
||||
- name: Fetch linuxdeploy (linux-portable only, SHA-verified)
|
||||
- name: Fetch appimagetool (linux-portable only, SHA-verified)
|
||||
if: matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
actual=$(sha256sum desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}')
|
||||
if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then
|
||||
echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual"
|
||||
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
|
||||
sudo apt-get update && sudo apt-get install -y desktop-file-utils
|
||||
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
|
||||
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
- name: Build desktop artifacts
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
@@ -204,7 +209,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-13, arch: x64, family: macos, tasks: "amyImage" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
173
.github/workflows/smoke-test-desktop.yml
vendored
Normal file
173
.github/workflows/smoke-test-desktop.yml
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
name: Desktop Smoke Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'desktopApp/**'
|
||||
- '.github/workflows/smoke-test-desktop.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: smoke-desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------------------
|
||||
# 1) Compose UI test — verifies the composable tree renders (login screen)
|
||||
# under the dev classpath. Catches missing string resources, broken
|
||||
# composables, and basic dependency-graph issues.
|
||||
# -------------------------------------------------------------------------
|
||||
compose-ui-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
- name: Run desktop tests (including UI smoke test)
|
||||
run: xvfb-run --auto-servernum ./gradlew :desktopApp:test
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2) Release .deb build + launch — builds the ProGuard'd, jlink'd .deb
|
||||
# package, installs it, and verifies the process stays alive for 10s.
|
||||
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
|
||||
# (java.management, java.prefs), and native lib bundling issues.
|
||||
# -------------------------------------------------------------------------
|
||||
release-deb-launch:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Cache vlc-setup downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/vlcSetup
|
||||
key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }}
|
||||
restore-keys: |
|
||||
vlcsetup-Linux-
|
||||
|
||||
- name: Pre-fetch VLC + UPX archives
|
||||
env:
|
||||
VLC_VERSION: "3.0.20"
|
||||
UPX_VERSION: "4.2.4"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEST="$HOME/.gradle/vlcSetup"
|
||||
mkdir -p "$DEST"
|
||||
fetch() {
|
||||
local url="$1" out="$2"
|
||||
if [[ -s "$out" ]]; then echo "cached: $out"; return 0; fi
|
||||
echo "fetching: $url"
|
||||
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
|
||||
--retry-max-time 900 --connect-timeout 30 \
|
||||
-o "$out.part" "$url"
|
||||
mv "$out.part" "$out"
|
||||
}
|
||||
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
|
||||
"$DEST/vlc-${VLC_VERSION}.jar"
|
||||
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
|
||||
"$DEST/upx-${UPX_VERSION}.tar.xz"
|
||||
|
||||
- name: Install xvfb + packaging deps
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot
|
||||
|
||||
- name: Build release .deb
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
with:
|
||||
max_attempts: 2
|
||||
timeout_minutes: 20
|
||||
command: ./gradlew --no-daemon :desktopApp:packageReleaseDeb
|
||||
|
||||
- name: Relax libicu dependency
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Install .deb
|
||||
run: |
|
||||
# jpackage's post-install script runs xdg-desktop-menu which fails
|
||||
# on CI runners ("No writable system menu directory"). The files are
|
||||
# extracted successfully; only the menu registration fails. Allow the
|
||||
# dpkg error, then verify the binary was actually installed.
|
||||
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
|
||||
echo "Installed files:"
|
||||
dpkg -L amethyst | head -30
|
||||
# Fail if the binary wasn't actually extracted
|
||||
test -d /opt/amethyst || test -d /opt/Amethyst || {
|
||||
echo "FAIL: /opt/amethyst not found after dpkg -i"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Smoke test — app launches and stays alive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Find the launcher binary
|
||||
LAUNCHER=$(find /opt -name "Amethyst" -type f -executable 2>/dev/null | head -1)
|
||||
if [[ -z "$LAUNCHER" ]]; then
|
||||
# Fallback: search dpkg file list
|
||||
LAUNCHER=$(dpkg -L amethyst | grep -E '/bin/[Aa]methyst$' | head -1)
|
||||
fi
|
||||
if [[ -z "$LAUNCHER" ]]; then
|
||||
echo "FAIL: could not find Amethyst launcher binary"
|
||||
dpkg -L amethyst
|
||||
exit 1
|
||||
fi
|
||||
echo "Launcher: $LAUNCHER"
|
||||
|
||||
# Launch under xvfb with a timeout safety net
|
||||
xvfb-run --auto-servernum timeout 30 "$LAUNCHER" &
|
||||
APP_PID=$!
|
||||
echo "PID: $APP_PID"
|
||||
|
||||
# Wait 10s — if the process is still alive, the app launched successfully
|
||||
sleep 10
|
||||
|
||||
if kill -0 "$APP_PID" 2>/dev/null; then
|
||||
echo "PASS: Application launched and stayed alive for 10s"
|
||||
kill "$APP_PID" || true
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
else
|
||||
wait "$APP_PID" 2>/dev/null
|
||||
EXIT_CODE=$?
|
||||
echo "FAIL: Application exited with code $EXIT_CODE within 10s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload .deb artifact (for manual testing)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Release DEB (smoke-tested)
|
||||
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -167,8 +167,7 @@ desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
|
||||
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
|
||||
desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
desktopApp/packaging/appimage/linuxdeploy-extracted/
|
||||
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
desktopApp/packaging/appimage/squashfs-root/
|
||||
|
||||
# Git worktrees
|
||||
|
||||
32
BUILDING.md
32
BUILDING.md
@@ -27,7 +27,8 @@ Platform-specific:
|
||||
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage
|
||||
|
||||
Install Linux RPM tooling:
|
||||
|
||||
@@ -39,12 +40,15 @@ sudo apt-get install -y rpm fakeroot
|
||||
sudo dnf install -y rpm-build
|
||||
```
|
||||
|
||||
Install linuxdeploy locally (CI fetches its own — SHA-verified):
|
||||
Install appimagetool locally (CI fetches its own — SHA-verified):
|
||||
|
||||
```bash
|
||||
curl -fsSL -o desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage \
|
||||
https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
|
||||
sudo apt-get install -y desktop-file-utils
|
||||
|
||||
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
|
||||
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
```
|
||||
|
||||
---
|
||||
@@ -109,7 +113,7 @@ Where:
|
||||
|
||||
| Field | Values |
|
||||
|---|---------------------------------------------------------|
|
||||
| `<version>` | Tag stripped of leading `v` (e.g. `1.09.2`) |
|
||||
| `<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` |
|
||||
@@ -120,10 +124,10 @@ any change is a breaking contract.
|
||||
|
||||
Examples:
|
||||
|
||||
- `amethyst-desktop-1.09.2-macos-x64.dmg`
|
||||
- `amethyst-desktop-1.09.2-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.09.2-windows-x64.msi`
|
||||
- `amethyst-desktop-1.09.2-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`
|
||||
|
||||
---
|
||||
|
||||
@@ -143,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())
|
||||
```
|
||||
|
||||
@@ -219,8 +223,8 @@ or equivalent issue tracker. On rotation, paste new token and run
|
||||
|
||||
```bash
|
||||
brew bump-cask-pr amethyst-nostr \
|
||||
--version 1.09.2 \
|
||||
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-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
|
||||
@@ -231,7 +235,7 @@ auto-submits new version bumps on each stable release.
|
||||
|
||||
```bash
|
||||
wingetcreate new \
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-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
CHANGELOG.md
105
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 = 445
|
||||
versionName = generateVersionName(libs.versions.app.get())
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"b1b91d7ee0c5da9d081d1a53470248ee4585b058b11aa34fe28c0e3e07ac1e0a\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
resourceConfigurations += [
|
||||
'ar',
|
||||
'ar-rSA',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cs-rCZ',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'de-rDE',
|
||||
'el-rGR',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'eo-rUY',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fa-rIR',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'fr-rFR',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'hu-rHU',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'ja-rJP',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'nl-rNL',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rRU',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'ta-rIN',
|
||||
'th',
|
||||
'th-rTH',
|
||||
'tr',
|
||||
'tr-rTR',
|
||||
'uk',
|
||||
'uk-rUA',
|
||||
'ur-rIN',
|
||||
'uz-rUZ',
|
||||
'vi-rVN',
|
||||
'zh',
|
||||
'zh-rCN',
|
||||
'zh-rHK',
|
||||
'zh-rSG',
|
||||
'zh-rTW'
|
||||
]
|
||||
}
|
||||
|
||||
// Opt-in fast-build flags. Default behavior is unchanged.
|
||||
//
|
||||
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
|
||||
// APK per (flavor, buildType) instead of 5.
|
||||
// Cuts ~600 MB of intermediates and several
|
||||
// minutes off CI.
|
||||
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
|
||||
// extra universal APK output. (No effect
|
||||
// when disableAbiSplits is also set, since
|
||||
// there are no splits to add to.)
|
||||
// -Pamethyst.skipMapping=true disable R8 minification on release and
|
||||
// benchmark. APK is larger, but builds are
|
||||
// much faster and outputs/mapping/ (~260MB)
|
||||
// is not produced. Local-dev and PR-CI use
|
||||
// only — release pipelines must not set it.
|
||||
def disableAbiSplits = providers.gradleProperty("disableAbiSplits")
|
||||
.map { it.toBoolean() }.getOrElse(false)
|
||||
def disableUniversalApk = providers.gradleProperty("disableUniversalApk")
|
||||
.map { it.toBoolean() }.getOrElse(false)
|
||||
def skipMapping = providers.gradleProperty("amethyst.skipMapping")
|
||||
.map { it.toBoolean() }.getOrElse(false)
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
|
||||
minifyEnabled = !skipMapping
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
versionNameSuffix '-DEBUG'
|
||||
resValue "string", "app_name", "@string/app_name_debug"
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix '.benchmark'
|
||||
versionNameSuffix '-BENCHMARK'
|
||||
resValue "string", "app_name", "@string/app_name_benchmark"
|
||||
profileable = true
|
||||
signingConfig = signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove this when lightcompressor uses one MP4 parser only
|
||||
packaging {
|
||||
resources {
|
||||
resources.pickFirsts.add('builddef.lst')
|
||||
resources.pickFirsts.add('META-INF/LICENSE.md')
|
||||
resources.pickFirsts.add('META-INF/LICENSE-notice.md')
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions = ["channel"]
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
getIsDefault().set(true)
|
||||
dimension "channel"
|
||||
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "true"
|
||||
}
|
||||
|
||||
fdroid {
|
||||
dimension "channel"
|
||||
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "false"
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable = !disableAbiSplits
|
||||
reset()
|
||||
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
|
||||
universalApk = !disableUniversalApk
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
|
||||
configurations.all {
|
||||
def tink = "com.google.crypto.tink:tink-android:1.17.0"
|
||||
resolutionStrategy {
|
||||
force(tink)
|
||||
dependencySubstitution {
|
||||
substitute module('com.google.crypto.tink:tink') using module(tink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_21
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform(libs.androidx.compose.bom)
|
||||
|
||||
implementation project(path: ':quartz')
|
||||
implementation project(path: ':commons')
|
||||
implementation project(path: ':nestsClient')
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.activity.compose
|
||||
|
||||
implementation libs.androidx.ui
|
||||
implementation libs.androidx.ui.graphics
|
||||
implementation libs.androidx.ui.tooling.preview
|
||||
|
||||
// Needs this to open gallery / image upload
|
||||
implementation libs.androidx.fragment.ktx
|
||||
|
||||
// Navigation
|
||||
implementation libs.androidx.navigation.compose
|
||||
|
||||
// Material 3 Design
|
||||
implementation libs.androidx.material3
|
||||
|
||||
// Adaptive Layout / Two Pane
|
||||
implementation libs.androidx.material3.windowSize
|
||||
implementation libs.accompanist.adaptive
|
||||
|
||||
// Lifecycle
|
||||
implementation libs.androidx.lifecycle.runtime.ktx
|
||||
implementation libs.androidx.lifecycle.runtime.compose
|
||||
implementation libs.androidx.lifecycle.viewmodel.compose
|
||||
|
||||
// Zoomable images
|
||||
implementation libs.zoomable
|
||||
|
||||
// Biometrics
|
||||
implementation libs.androidx.biometric.ktx
|
||||
|
||||
// Background Work
|
||||
implementation libs.androidx.work.runtime.ktx
|
||||
|
||||
// Websockets API
|
||||
implementation libs.okhttp
|
||||
implementation libs.okhttpCoroutines
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation libs.androidx.security.crypto.ktx
|
||||
implementation libs.androidx.datastore.preferences
|
||||
|
||||
// view videos
|
||||
implementation libs.androidx.media3.exoplayer
|
||||
implementation libs.androidx.media3.exoplayer.hls
|
||||
implementation libs.androidx.media3.ui.compose.material3
|
||||
implementation libs.androidx.media3.session
|
||||
|
||||
// important for proxy / tor
|
||||
implementation libs.androidx.media3.datasource.okhttp
|
||||
|
||||
// Load images from the web.
|
||||
implementation libs.coil.compose
|
||||
// view gifs
|
||||
implementation libs.coil.gif
|
||||
// view svgs
|
||||
implementation libs.coil.svg
|
||||
// enables network for coil
|
||||
implementation libs.coil.okhttp
|
||||
// loads thumbnails for media3
|
||||
// TODO: Replace this to the FrameExtractor in media 3
|
||||
// when FrameExtractor accepts custom data sources.
|
||||
implementation(libs.coil.video)
|
||||
|
||||
// Permission to upload pictures:
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
// For QR generation
|
||||
implementation libs.zxing
|
||||
implementation libs.zxing.embedded
|
||||
|
||||
// Markdown
|
||||
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
//implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation libs.markdown.ui
|
||||
implementation libs.markdown.ui.material3
|
||||
implementation libs.markdown.commonmark
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation libs.androidx.appcompat
|
||||
|
||||
// Dynamically adjust between phone and tablet UI
|
||||
implementation libs.androidx.window.core.android
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation libs.google.mlkit.language.id
|
||||
|
||||
// Google services model the translate text
|
||||
playImplementation libs.google.mlkit.translate
|
||||
|
||||
// On-device AI writing assistance (Gemini Nano via AICore)
|
||||
playImplementation libs.google.mlkit.genai.proofreading
|
||||
playImplementation libs.google.mlkit.genai.prompt
|
||||
playImplementation libs.google.mlkit.genai.rewriting
|
||||
|
||||
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
|
||||
// with image-labeling as a keyword-join fallback for devices without AICore.
|
||||
playImplementation libs.google.mlkit.genai.image.description
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform(libs.firebase.bom)
|
||||
playImplementation libs.firebase.messaging
|
||||
|
||||
//PushNotifications(FDroid)
|
||||
fdroidImplementation libs.unifiedpush
|
||||
|
||||
// Google Cast SDK — Chromecast support. Play flavor only because the
|
||||
// framework hard-depends on Google Play services, which is unavailable
|
||||
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
|
||||
playImplementation libs.play.services.cast.framework
|
||||
|
||||
// Charts
|
||||
implementation libs.vico.charts.compose
|
||||
implementation libs.vico.charts.m3
|
||||
|
||||
// Waveform visualizer
|
||||
implementation libs.audiowaveform
|
||||
|
||||
// Video compression lib
|
||||
implementation libs.abedElazizShe.video.compressor.fork
|
||||
// Image compression lib
|
||||
implementation libs.zelory.image.compressor
|
||||
|
||||
// EXIF metadata stripping
|
||||
implementation libs.androidx.exifinterface
|
||||
|
||||
// Voice anonymization DSP
|
||||
implementation libs.tarsosdsp
|
||||
|
||||
// WebRTC for voice/video calls
|
||||
implementation libs.stream.webrtc.android
|
||||
|
||||
// Cbor for cashuB format
|
||||
implementation libs.kotlinx.serialization.cbor
|
||||
|
||||
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.mockk
|
||||
testImplementation libs.kotlinx.coroutines.test
|
||||
|
||||
androidTestImplementation platform(libs.androidx.compose.bom)
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation libs.androidx.junit.ktx
|
||||
androidTestImplementation libs.androidx.espresso.core
|
||||
androidTestImplementation libs.androidx.ui.test.junit4
|
||||
androidTestImplementation libs.mockk.android
|
||||
|
||||
debugImplementation platform(libs.androidx.compose.bom)
|
||||
debugImplementation libs.androidx.ui.tooling
|
||||
debugImplementation libs.androidx.ui.test.manifest
|
||||
|
||||
implementation libs.androidx.camera.core
|
||||
implementation libs.androidx.camera.camera2
|
||||
implementation libs.androidx.camera.lifecycle
|
||||
implementation libs.androidx.camera.view
|
||||
implementation libs.androidx.camera.extensions
|
||||
}
|
||||
463
amethyst/build.gradle.kts
Normal file
463
amethyst/build.gradle.kts
Normal file
@@ -0,0 +1,463 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.googleServices)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
alias(libs.plugins.serialization)
|
||||
}
|
||||
|
||||
fun getCurrentBranch(): String =
|
||||
try {
|
||||
val process =
|
||||
ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val branch =
|
||||
process.inputStream
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
.trim()
|
||||
process.waitFor()
|
||||
branch
|
||||
} catch (e: Exception) {
|
||||
println("Could not determine git branch: ${e.message}")
|
||||
"unknown"
|
||||
}
|
||||
|
||||
fun generateVersionName(baseVersion: String): String {
|
||||
val currentBranch = getCurrentBranch()
|
||||
|
||||
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
|
||||
return baseVersion
|
||||
}
|
||||
|
||||
// Clean branch name for version (replace special characters)
|
||||
var cleanBranch = currentBranch.replace(Regex("[^a-zA-Z0-9\\-_]"), "-")
|
||||
|
||||
// Limit branch name to maximum 20 characters
|
||||
if (cleanBranch.length > 20) {
|
||||
cleanBranch = cleanBranch.substring(0, 20)
|
||||
}
|
||||
|
||||
return "$baseVersion-$cleanBranch"
|
||||
}
|
||||
|
||||
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
|
||||
afterEvaluate {
|
||||
val stabilityNames = tasks.names.filter { it.contains("StabilityCheck") }
|
||||
val compileNames = tasks.names.filter { it.matches(Regex("compile.*UnitTestKotlin")) }
|
||||
stabilityNames.forEach { scName ->
|
||||
compileNames.forEach { ctName ->
|
||||
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.vitorpamplona.amethyst"
|
||||
compileSdk =
|
||||
libs.versions.android.compileSdk
|
||||
.get()
|
||||
.toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk =
|
||||
libs.versions.android.minSdk
|
||||
.get()
|
||||
.toInt()
|
||||
targetSdk =
|
||||
libs.versions.android.targetSdk
|
||||
.get()
|
||||
.toInt()
|
||||
versionCode = 447
|
||||
versionName = generateVersionName(libs.versions.app.get())
|
||||
buildConfigField("String", "RELEASE_NOTES_ID", "\"8ec0d94550b5538115226c6858159b1115713c9c6ed942173bd4fd5d292d8ba6\"")
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
@Suppress("UnstableApiUsage")
|
||||
resourceConfigurations +=
|
||||
listOf(
|
||||
"ar",
|
||||
"ar-rSA",
|
||||
"bn-rBD",
|
||||
"cs",
|
||||
"cs-rCZ",
|
||||
"cy-rGB",
|
||||
"da-rDK",
|
||||
"de",
|
||||
"de-rDE",
|
||||
"el-rGR",
|
||||
"en-rGB",
|
||||
"eo",
|
||||
"eo-rUY",
|
||||
"es",
|
||||
"es-rES",
|
||||
"es-rMX",
|
||||
"es-rUS",
|
||||
"et-rEE",
|
||||
"fa",
|
||||
"fa-rIR",
|
||||
"fi-rFI",
|
||||
"fo-rFO",
|
||||
"fr",
|
||||
"fr-rCA",
|
||||
"fr-rFR",
|
||||
"gu-rIN",
|
||||
"hi-rIN",
|
||||
"hr-rHR",
|
||||
"hu",
|
||||
"hu-rHU",
|
||||
"in",
|
||||
"in-rID",
|
||||
"it-rIT",
|
||||
"iw-rIL",
|
||||
"ja",
|
||||
"ja-rJP",
|
||||
"kk-rKZ",
|
||||
"ko-rKR",
|
||||
"ks-rIN",
|
||||
"ku-rTR",
|
||||
"lt-rLT",
|
||||
"ne-rNP",
|
||||
"nl",
|
||||
"nl-rBE",
|
||||
"nl-rNL",
|
||||
"pcm-rNG",
|
||||
"pl-rPL",
|
||||
"pt-rBR",
|
||||
"pt-rPT",
|
||||
"ru",
|
||||
"ru-rRU",
|
||||
"ru-rUA",
|
||||
"sa-rIN",
|
||||
"sl-rSI",
|
||||
"so-rSO",
|
||||
"sr-rSP",
|
||||
"ss-rZA",
|
||||
"sv-rSE",
|
||||
"sw-rKE",
|
||||
"sw-rTZ",
|
||||
"ta",
|
||||
"ta-rIN",
|
||||
"th",
|
||||
"th-rTH",
|
||||
"tr",
|
||||
"tr-rTR",
|
||||
"uk",
|
||||
"uk-rUA",
|
||||
"ur-rIN",
|
||||
"uz-rUZ",
|
||||
"vi-rVN",
|
||||
"zh",
|
||||
"zh-rCN",
|
||||
"zh-rHK",
|
||||
"zh-rSG",
|
||||
"zh-rTW",
|
||||
)
|
||||
}
|
||||
|
||||
// Opt-in fast-build flags. Default behavior is unchanged.
|
||||
//
|
||||
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
|
||||
// APK per (flavor, buildType) instead of 5.
|
||||
// Cuts ~600 MB of intermediates and several
|
||||
// minutes off CI.
|
||||
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
|
||||
// extra universal APK output. (No effect
|
||||
// when disableAbiSplits is also set, since
|
||||
// there are no splits to add to.)
|
||||
// -Pamethyst.skipMapping=true disable R8 minification on release and
|
||||
// benchmark. APK is larger, but builds are
|
||||
// much faster and outputs/mapping/ (~260MB)
|
||||
// is not produced. Local-dev and PR-CI use
|
||||
// only — release pipelines must not set it.
|
||||
val disableAbiSplits =
|
||||
providers
|
||||
.gradleProperty("disableAbiSplits")
|
||||
.map { it.toBoolean() }
|
||||
.getOrElse(false)
|
||||
val disableUniversalApk =
|
||||
providers
|
||||
.gradleProperty("disableUniversalApk")
|
||||
.map { it.toBoolean() }
|
||||
.getOrElse(false)
|
||||
val skipMapping =
|
||||
providers
|
||||
.gradleProperty("amethyst.skipMapping")
|
||||
.map { it.toBoolean() }
|
||||
.getOrElse(false)
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
isMinifyEnabled = !skipMapping
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
resValue("string", "app_name", "@string/app_name_debug")
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix = ".benchmark"
|
||||
versionNameSuffix = "-BENCHMARK"
|
||||
resValue("string", "app_name", "@string/app_name_benchmark")
|
||||
isProfileable = true
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove this when lightcompressor uses one MP4 parser only
|
||||
packaging {
|
||||
resources {
|
||||
pickFirsts.add("builddef.lst")
|
||||
pickFirsts.add("META-INF/LICENSE.md")
|
||||
pickFirsts.add("META-INF/LICENSE-notice.md")
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "channel"
|
||||
|
||||
productFlavors {
|
||||
create("play") {
|
||||
isDefault = true
|
||||
dimension = "channel"
|
||||
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "true")
|
||||
}
|
||||
|
||||
create("fdroid") {
|
||||
dimension = "channel"
|
||||
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "false")
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = !disableAbiSplits
|
||||
reset()
|
||||
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
|
||||
isUniversalApk = !disableUniversalApk
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += "MissingTranslation"
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
|
||||
configurations.all {
|
||||
val tink = "com.google.crypto.tink:tink-android:1.17.0"
|
||||
resolutionStrategy {
|
||||
force(tink)
|
||||
dependencySubstitution {
|
||||
substitute(module("com.google.crypto.tink:tink")).using(module(tink))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
implementation(project(":quartz"))
|
||||
implementation(project(":commons"))
|
||||
implementation(project(":nestsClient"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
|
||||
// Needs this to open gallery / image upload
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Material 3 Design
|
||||
implementation(libs.androidx.material3)
|
||||
|
||||
// Adaptive Layout / Two Pane
|
||||
implementation(libs.androidx.material3.windowSize)
|
||||
implementation(libs.accompanist.adaptive)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Zoomable images
|
||||
implementation(libs.zoomable)
|
||||
|
||||
// Biometrics
|
||||
implementation(libs.androidx.biometric.ktx)
|
||||
|
||||
// Background Work
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
||||
// Websockets API
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttpCoroutines)
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation(libs.androidx.security.crypto.ktx)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// view videos
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.ui.compose.material3)
|
||||
implementation(libs.androidx.media3.session)
|
||||
|
||||
// important for proxy / tor
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
|
||||
// Load images from the web.
|
||||
implementation(libs.coil.compose)
|
||||
// view gifs
|
||||
implementation(libs.coil.gif)
|
||||
// view svgs
|
||||
implementation(libs.coil.svg)
|
||||
// enables network for coil
|
||||
implementation(libs.coil.okhttp)
|
||||
// loads thumbnails for media3
|
||||
// TODO: Replace this to the FrameExtractor in media 3
|
||||
// when FrameExtractor accepts custom data sources.
|
||||
implementation(libs.coil.video)
|
||||
|
||||
// Permission to upload pictures:
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// For QR generation
|
||||
implementation(libs.zxing)
|
||||
implementation(libs.zxing.embedded)
|
||||
|
||||
// Markdown
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
// implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation(libs.markdown.ui)
|
||||
implementation(libs.markdown.ui.material3)
|
||||
implementation(libs.markdown.commonmark)
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
// Dynamically adjust between phone and tablet UI
|
||||
implementation(libs.androidx.window.core.android)
|
||||
|
||||
// Local model for language identification
|
||||
"playImplementation"(libs.google.mlkit.language.id)
|
||||
|
||||
// Google services model the translate text
|
||||
"playImplementation"(libs.google.mlkit.translate)
|
||||
|
||||
// On-device AI writing assistance (Gemini Nano via AICore)
|
||||
"playImplementation"(libs.google.mlkit.genai.proofreading)
|
||||
"playImplementation"(libs.google.mlkit.genai.prompt)
|
||||
"playImplementation"(libs.google.mlkit.genai.rewriting)
|
||||
|
||||
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
|
||||
// with image-labeling as a keyword-join fallback for devices without AICore.
|
||||
"playImplementation"(libs.google.mlkit.genai.image.description)
|
||||
|
||||
// PushNotifications
|
||||
"playImplementation"(platform(libs.firebase.bom))
|
||||
"playImplementation"(libs.firebase.messaging)
|
||||
|
||||
// PushNotifications(FDroid)
|
||||
"fdroidImplementation"(libs.unifiedpush)
|
||||
|
||||
// Google Cast SDK — Chromecast support. Play flavor only because the
|
||||
// framework hard-depends on Google Play services, which is unavailable
|
||||
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
|
||||
"playImplementation"(libs.play.services.cast.framework)
|
||||
|
||||
// Charts
|
||||
implementation(libs.vico.charts.compose)
|
||||
implementation(libs.vico.charts.m3)
|
||||
|
||||
// Waveform visualizer
|
||||
implementation(libs.audiowaveform)
|
||||
|
||||
// Video compression lib
|
||||
implementation(libs.abedElazizShe.video.compressor.fork)
|
||||
// Image compression lib
|
||||
implementation(libs.zelory.image.compressor)
|
||||
|
||||
// EXIF metadata stripping
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
// Voice anonymization DSP
|
||||
implementation(libs.tarsosdsp)
|
||||
|
||||
// WebRTC for voice/video calls
|
||||
implementation(libs.stream.webrtc.android)
|
||||
|
||||
// Cbor for cashuB format
|
||||
implementation(libs.kotlinx.serialization.cbor)
|
||||
|
||||
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.junit.ktx)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
|
||||
debugImplementation(platform(libs.androidx.compose.bom))
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.camera.extensions)
|
||||
}
|
||||
367
amethyst/plans/2026-05-14-onchain-zaps.md
Normal file
367
amethyst/plans/2026-05-14-onchain-zaps.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# Onchain Zaps in Amethyst
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Status:** Active
|
||||
|
||||
Implementation plan for NIP-BC (kind 8333) onchain Bitcoin zaps in Amethyst Android.
|
||||
Quartz now ships the `OnchainZapEvent` data model; this document describes the
|
||||
rest of the system.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- **Wallet model.** Non-custodial. The user's Nostr pubkey IS the BIP-341
|
||||
internal key of a P2TR output, so every account has exactly one onchain
|
||||
address derived from its identity. No seed phrase, no separate wallet
|
||||
creation.
|
||||
- **Signers.** v1 supports `NostrSignerInternal` (local keypair) and
|
||||
`NostrSignerExternal` (NIP-55 Android external signer). The NIP-55 path is
|
||||
broken until Amber implements `sign_psbt`; surface "update your signer"
|
||||
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. **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.
|
||||
|
||||
### Architecture decision: hand-rolled Bitcoin consensus code (2026-05-14)
|
||||
|
||||
**Decision:** keep the hand-rolled Bitcoin consensus layer in
|
||||
`quartz/.../nipBCOnchainZaps/{psbt,taproot}/` — the transaction codec,
|
||||
serialization/txid, BIP-341 sighash, BIP-174 PSBT codec/signer/finalizer, and
|
||||
BIP-341/350 address derivation. Do **not** pull in `fr.acinq.bitcoin-kmp`.
|
||||
|
||||
**Considered alternative:** replace the `psbt/` + transaction + sighash layer
|
||||
with `fr.acinq.bitcoin-kmp` (mature, same vendor as the `secp256k1` binding
|
||||
already in the build).
|
||||
|
||||
**Rationale for keeping it hand-rolled:**
|
||||
- It is a deliberately small, constrained subset — single-key-path P2TR only,
|
||||
no script trees, one transaction shape.
|
||||
- It is pinned to authoritative external test vectors at *every* layer:
|
||||
BIP-341 sighash (all 7 vectors + ANYONECANPAY), the BIP-341 tweak, the full
|
||||
BIP-341 witness *signature bytes*, the 7 BIP-341/350 P2TR mainnet addresses,
|
||||
and tx serialization against the genesis coinbase. It is "matches the
|
||||
authoritative vectors," not "trust our code."
|
||||
- Consistent with the project's stance on minimal dependencies (cf. the
|
||||
from-scratch `quic` module).
|
||||
- No new transitive dependencies or version-conflict surface.
|
||||
|
||||
**Consequence / what this commits us to:** we own the correctness of this code
|
||||
forever. If the scope ever expands beyond single-key-path P2TR (script-path
|
||||
spends, multisig, PSBT fields we don't model), revisit this decision — at that
|
||||
point a vetted library is the better trade. The `nipBCOnchainZaps/{psbt,taproot}/`
|
||||
packages carry a pointer back to this section.
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
| Layer | Concerns | Location |
|
||||
|---|---|---|
|
||||
| Quartz | Address derivation, PSBT codec, Esplora client, NIP-BC verifier, `signPsbt` on signer hierarchy | `quartz/.../nipBCOnchainZaps/{taproot,psbt,chain,build,verify}/` |
|
||||
| Commons | Send/wallet ViewModels, shared composables | `commons/.../onchain/` |
|
||||
| Amethyst | `OnchainSection` in existing `WalletScreen`, `LocalCache.consume(OnchainZapEvent)`, kind list edits in 8 filter files, NIP-55 intent plumbing | `amethyst/.../ui/onchain/`, `amethyst/.../model/LocalCache.kt`, existing filter files |
|
||||
|
||||
## Merging into the existing wallet UI
|
||||
|
||||
The existing `WalletScreen` is a NIP-47 NWC multi-wallet manager. Onchain wallet
|
||||
fits as a separate top section, since it has different semantics (single
|
||||
deterministic wallet per account, no NWC URI, chain-backed).
|
||||
|
||||
```
|
||||
WalletScreen
|
||||
├── TopAppBar
|
||||
├── BitcoinSection ← NEW, single card
|
||||
│ └── OnchainWalletCard (balance, bc1p address, tap → OnchainWalletDetailScreen)
|
||||
└── LightningSection ← existing MultiWalletHomeContent
|
||||
├── NoWalletSetup
|
||||
└── NwcWalletCard × N
|
||||
```
|
||||
|
||||
The "+" `Add wallet` icon still adds NWC entries only — onchain is implicit.
|
||||
Section labels: "Bitcoin" and "Lightning".
|
||||
|
||||
## Subscription edits — extend existing kind lists
|
||||
|
||||
No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing
|
||||
`LnZapEvent.KIND` (9735) sites:
|
||||
|
||||
| File | Edit |
|
||||
|---|---|
|
||||
| `amethyst/.../FilterRepliesAndReactionsToNotes.kt:48-60` | Add to `RepliesAndReactionsKinds` (note `#e`) |
|
||||
| `amethyst/.../FilterUserProfileZapReceived.kt:30` | Add to `UserProfileZapReceiverKinds` (profile `#p`) |
|
||||
| `amethyst/.../zaps/dal/UserProfileZapsViewModel.kt:55` | Add to inline `kinds = listOf(...)` |
|
||||
| `amethyst/.../FilterNotificationsToPubkey.kt:58-65` | Add to `SummaryKinds` (notifications `#p`) |
|
||||
| `amethyst/.../NotificationFeedFilter.kt:123` | Add to `NOTIFICATION_KINDS` |
|
||||
| `amethyst/.../NotificationDispatcher.kt:98` | Add to `NOTIFICATION_KINDS` |
|
||||
| `amethyst/.../FilterMessagesToLiveStream.kt:46` | Add to live-activity zap kinds (`#a`) |
|
||||
| `amethyst/.../FilterGoalForLiveActivity.kt:58` | Add to goal-zap kinds (`#e`) |
|
||||
|
||||
## Display path — fold into `Note.zapsAmount`
|
||||
|
||||
Today: `LocalCache.consume(LnZapEvent)` → `Note.addZap()` → `Note.updateZapTotal()`
|
||||
sums lightning amounts into `Note.zapsAmount`, which `ReactionsRow` /
|
||||
`ObserveZapAmountText` / `SlidingAnimationAmount` render. We add onchain zap
|
||||
sats to the same `Note.zapsAmount` — no UI changes required.
|
||||
|
||||
- `commons/.../model/Note.kt:154-157, 621-632`
|
||||
- Add `var onchainZaps = mapOf<...>()` (separate map from `zaps`).
|
||||
- Extend `updateZapTotal()` to add **verified** onchain sats. Unverified or
|
||||
pending tx amounts are NOT counted.
|
||||
- `amethyst/.../model/LocalCache.kt` (after the `consume(LnZapEvent)` block ~line 1667)
|
||||
- New `consume(event: OnchainZapEvent)` handler.
|
||||
- Reject self-zap.
|
||||
- Enqueue verification against the configured `OnchainBackend`.
|
||||
- On success: `Note.addOnchainZap(event, verifiedSats)` on each `repliesTo`.
|
||||
- On failure or zero verified amount: discard.
|
||||
- Dedupe by `(txid, target)`.
|
||||
|
||||
## Quartz additions
|
||||
|
||||
- `nipBCOnchainZaps/taproot/`
|
||||
- `SegwitAddress.kt` — bech32m segwit address encoder/decoder
|
||||
- `TaprootAddress.kt` — Nostr pubkey → bc1p P2TR address via BIP-341
|
||||
key-path-only tweak (uses `Secp256k1Instance.pubKeyTweakAdd`)
|
||||
- `nipBCOnchainZaps/chain/`
|
||||
- `OnchainBackend` interface — `getTx`, `getUtxos`, `broadcast`,
|
||||
`tipHeight`, `feeEstimates`
|
||||
- `BitcoinTx`, `BitcoinTxOutput`, `Utxo` data models
|
||||
- `BitcoinTxParser` — minimal raw-tx parser (just enough for verification)
|
||||
- `EsploraBackend` (jvmAndroid) — OkHttp impl
|
||||
- `nipBCOnchainZaps/verify/`
|
||||
- `OnchainZapVerifier` — implements all spec rules: reject self-zap, sum
|
||||
only outputs paying the derived recipient address, dedupe `(txid, target)`,
|
||||
cap claimed amount at verified amount
|
||||
- `nipBCOnchainZaps/psbt/` (Phase A.2)
|
||||
- Minimal BIP-174 codec
|
||||
- `PsbtTaprootKeyPathSigner` — BIP-341 TapTweak + Schnorr sign
|
||||
- `nipBCOnchainZaps/build/` (Phase A.2)
|
||||
- `OnchainZapBuilder` — given (sender, recipient, sats, feeRate, utxos),
|
||||
build a PSBT with change back to sender's Taproot
|
||||
- `NostrSigner.signPsbt` (Phase A.2)
|
||||
- Internal: signs directly
|
||||
- External (NIP-55): launches `sign_psbt` intent — needs Amber update
|
||||
- Remote (NIP-46): `NotSupportedException` stub
|
||||
|
||||
## Commons additions
|
||||
|
||||
- `OnchainWalletViewModel` — derived address, balance, UTXO list
|
||||
- `OnchainZapSendViewModel` — build → sign → broadcast → publish kind 8333
|
||||
- `OnchainZapSendDialog`, `OnchainAddressQrCard`, `FeeRatePicker`
|
||||
|
||||
## Account state
|
||||
|
||||
In `AccountSettings.kt` next to `nwcWallets`:
|
||||
|
||||
```kotlin
|
||||
val onchainEsploraEndpoint: MutableStateFlow<String> // default mempool.space
|
||||
val onchainDefaultFeeTier: MutableStateFlow<FeeTier> // SLOW / NORMAL / FAST
|
||||
```
|
||||
|
||||
Derived `Account.onchainBalance: StateFlow<Long?>` populated by
|
||||
`OnchainWalletViewModel`.
|
||||
|
||||
## Send-from-note merge
|
||||
|
||||
Existing `ZapAmountChoicePopup` (`ReactionsRow.kt:1877-1977`) stays as the
|
||||
Lightning fast path. Two minimal hooks:
|
||||
|
||||
1. Append `[ ⛓ Onchain… ]` row to the popup. Tapping it opens
|
||||
`OnchainZapSendDialog` (fee picker + confirmation), separate from the
|
||||
instant-tap Lightning UX.
|
||||
2. Optional settings toggle "Show onchain zap option" — defaults off until a
|
||||
balance is observed.
|
||||
|
||||
## Phased delivery
|
||||
|
||||
| Phase | Deliverable | Status |
|
||||
|---|---|---|
|
||||
| **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, verifier + tests | **Shipped** |
|
||||
| **C** | Receive + display: `OnchainSection` in `WalletScreen` (address + live balance), Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 7 in-scope filter files | **Shipped** |
|
||||
| **A.2** | Quartz foundation (send side): BIP-174 PSBT codec, BIP-341 sighash, `OnchainZapBuilder`, `signPsbt` on the `NostrSigner` hierarchy. All crypto validated against BIP-341 wallet test vectors (31 tests). | **Shipped** |
|
||||
| **D** | Send flow: `OnchainZapSender` orchestrator, `Account.sendOnchainZap`, `OnchainZapSendDialog`, "Send" button on the wallet `OnchainSection`. | **Shipped** |
|
||||
| **B** | NIP-55 `sign_psbt` Intent + ContentResolver contract, wired through `NostrSignerExternal.signPsbt`. Works once the external signer app (Amber etc.) ships `sign_psbt` support — older signers reply with no `result`, surfaced as a send failure. | **Shipped** |
|
||||
|
||||
### Phase A.2 — shipped
|
||||
|
||||
- `nipBCOnchainZaps/psbt/`: `BitcoinIO` (LE byte codec + varint), `BitcoinTransaction`
|
||||
(legacy + segwit serialization, witness-stripped txid), `TaprootSigHash`
|
||||
(BIP-341 SigMsg + TapSighash, all sighash types), `Psbt` (BIP-174 subset,
|
||||
unknown-record-preserving), `PsbtSigner` (key-path signing), `PsbtFinalizer`.
|
||||
- `nipBCOnchainZaps/build/OnchainZapBuilder`: largest-first coin selection +
|
||||
unsigned-PSBT assembly with dust-aware change.
|
||||
- `TaprootAddress.tweakSecretKey`: BIP-341 `taproot_tweak_seckey`.
|
||||
- `Secp256k1Instance.privKeyNegate`: new primitive (commonMain + 3 actuals).
|
||||
- `NostrSigner.signPsbt`: real impl on internal signer; delegated by the
|
||||
client-tag wrapper; `UnsupportedMethodException` stubs on NIP-46 / NIP-55.
|
||||
|
||||
### Phase D — shipped
|
||||
|
||||
- `commons/onchain/OnchainZapSender`: stateless orchestrator —
|
||||
load UTXOs → `OnchainZapBuilder.build` → `signer.signPsbt` →
|
||||
`PsbtFinalizer` → `OnchainBackend.broadcast` → publish kind:8333 via an
|
||||
injected callback. Per-stage `OnchainZapSendResult.Failure`; broadcast txid
|
||||
preserved when only the receipt-publish stage fails.
|
||||
- `Account.sendOnchainZap`: binds the account signer, `LocalCache.onchainBackend`,
|
||||
and `signAndComputeBroadcast` into the orchestrator.
|
||||
- `OnchainZapSendDialog`: recipient npub (or fixed recipient + `zappedEvent`
|
||||
for a future note-zap-menu entry), amount, fee tier from the backend's
|
||||
estimates, comment; runs the send and shows progress + result.
|
||||
- `OnchainSection`: "Send" button on the wallet-screen Bitcoin card.
|
||||
|
||||
### Phase B — shipped
|
||||
|
||||
- `CommandType.SIGN_PSBT` (`sign_psbt`) — also usable in NIP-55 `perms` lists
|
||||
via `Permission`, which wraps `CommandType` directly.
|
||||
- `SignPsbtResult` result type; `SignPsbtQuery` (background ContentResolver),
|
||||
`SignPsbtRequest` / `SignPsbtResponse` (foreground Intent), mirroring the
|
||||
`derive_key` string-in/string-out shape — the PSBT hex rides the
|
||||
`nostrsigner:` URI, the signed PSBT comes back in `result`.
|
||||
- `BackgroundRequestHandler.signPsbt` / `ForegroundRequestHandler.signPsbt`;
|
||||
`NostrSignerExternal.signPsbt` now does the real background-then-foreground
|
||||
query instead of throwing. External signers that predate `sign_psbt` reply
|
||||
with no `result` → `CouldNotPerformException`, surfaced by the send dialog.
|
||||
|
||||
### What's still pending
|
||||
|
||||
1. **Note-zap-menu entry point.** `OnchainZapSendDialog` already accepts
|
||||
`recipientPubKey` + `zappedEvent`; wiring an "Onchain" option into the
|
||||
existing `ZapAmountChoicePopup` is the remaining UI hook for event zaps.
|
||||
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
|
||||
|
||||
- **secp256k1-kmp tweak coverage** — pubKeyTweakAdd exists on JVM/Android/JNI
|
||||
but not on the pure-Kotlin native impl. iOS support deferred until upstream
|
||||
ships it or we contribute.
|
||||
- **PSBT correctness** — single-key-path-only is a small surface; still needs
|
||||
thorough testing against BIP-174 test vectors before any user funds move.
|
||||
- **Esplora privacy** — the configured server sees user UTXO queries. Default
|
||||
to a reputable provider, make user-configurable, consider future Tor option.
|
||||
- **NIP-55 ecosystem** — Amber must implement `sign_psbt` for external signer
|
||||
accounts to use this feature.
|
||||
- **`Note.zaps` shape** — separate `onchainZaps` map vs sealed-type fold-in.
|
||||
Leaning separate map for v1.
|
||||
- **Verification network calls from `LocalCache`** — `consume` runs on the
|
||||
relay thread; verification needs a coroutine scope + `OnchainBackend`
|
||||
instance injected from `Account` on app start.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -207,24 +207,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
|
||||
)
|
||||
}
|
||||
|
||||
fun hasPushNotificationProvider(): Boolean = true
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
|
||||
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
|
||||
SettingsRow(
|
||||
R.string.push_server_title,
|
||||
R.string.push_server_explainer,
|
||||
selectedItems = readableListWithExplainer,
|
||||
selectedIndex = list.indexOf(currentDistributor),
|
||||
) { index ->
|
||||
if (list[index] == "None") {
|
||||
sharedPrefs.dontAskForNotificationPermissions()
|
||||
sharedPrefs.dontShowPushNotificationSelector()
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
} else {
|
||||
PushDistributorHandler.saveDistributor(list[index])
|
||||
}
|
||||
val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0)
|
||||
SettingsBlockTile(
|
||||
icon = MaterialSymbols.CloudSync,
|
||||
title = stringRes(R.string.push_server_title),
|
||||
description = stringRes(R.string.push_server_explainer),
|
||||
) {
|
||||
TextSpinner(
|
||||
label = null,
|
||||
placeholder = readableListWithExplainer[selectedIndex].title,
|
||||
options = readableListWithExplainer,
|
||||
onSelect = { index ->
|
||||
if (list[index] == "None") {
|
||||
sharedPrefs.dontAskForNotificationPermissions()
|
||||
sharedPrefs.dontShowPushNotificationSelector()
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
} else {
|
||||
PushDistributorHandler.saveDistributor(list[index])
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,13 @@ 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
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
|
||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
|
||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
||||
@@ -91,6 +93,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
|
||||
@@ -99,6 +102,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -330,6 +335,40 @@ class AppModules(
|
||||
// Caches all events in Memory
|
||||
val cache: LocalCache = LocalCache
|
||||
|
||||
// NIP-BC onchain zap verification backend. Wired up once at app init so
|
||||
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
|
||||
// that pay the recipient's derived Taproot address. Wrapped in a caching
|
||||
// decorator so a feed full of onchain zaps doesn't fan out into one HTTP
|
||||
// request per event.
|
||||
//
|
||||
// The explorer endpoint is shared with OpenTimestamps: it honours the same
|
||||
// user-configured server (OTS settings) and the same Tor-aware default
|
||||
// selection, via BitcoinExplorerEndpoint — onchain zaps must not silently
|
||||
// bypass the user's Tor preference.
|
||||
init {
|
||||
cache.onchainBackend =
|
||||
CachingOnchainBackend(
|
||||
EsploraBackend(
|
||||
baseUrl = {
|
||||
BitcoinExplorerEndpoint.resolveNormalized(
|
||||
customExplorerUrl = otsPrefs.current.normalizedUrl(),
|
||||
usingTor =
|
||||
roleBasedHttpClientBuilder.shouldUseTorForMoneyOperations(
|
||||
OkHttpBitcoinExplorer.MEMPOOL_API_URL,
|
||||
),
|
||||
)
|
||||
},
|
||||
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
|
||||
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
|
||||
|
||||
@@ -609,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"
|
||||
@@ -361,6 +362,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))
|
||||
@@ -641,6 +643,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 +715,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 +737,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),
|
||||
|
||||
@@ -36,6 +36,10 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListS
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
|
||||
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
|
||||
@@ -401,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)
|
||||
@@ -486,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
|
||||
|
||||
@@ -597,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
|
||||
|
||||
@@ -693,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()),
|
||||
@@ -701,6 +712,8 @@ class Account(
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
toUserPubHex = toUser?.pubkeyHex,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
|
||||
suspend fun calculateIfNoteWasZappedByAccount(
|
||||
@@ -740,6 +753,8 @@ class Account(
|
||||
user: User,
|
||||
message: String = "",
|
||||
zapType: LnZapEvent.ZapType,
|
||||
amountMillisats: Long? = null,
|
||||
lnurl: String? = null,
|
||||
): LnZapRequestEvent {
|
||||
val zapRequest =
|
||||
LnZapRequestEvent.create(
|
||||
@@ -748,12 +763,73 @@ class Account(
|
||||
signer = signer,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
|
||||
cache.justConsumeMyOwnEvent(zapRequest)
|
||||
return zapRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
|
||||
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
|
||||
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
|
||||
* leave it null for a profile zap.
|
||||
*/
|
||||
suspend fun sendOnchainZap(
|
||||
recipientPubKey: HexKey,
|
||||
amountSats: Long,
|
||||
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.send(
|
||||
backend = backend,
|
||||
signer = signer,
|
||||
senderPubKey = signer.pubKey,
|
||||
recipientPubKey = recipientPubKey,
|
||||
amountSats = amountSats,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
comment = comment,
|
||||
zappedEvent = zappedEvent,
|
||||
) { 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,
|
||||
@@ -2090,6 +2166,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.
|
||||
@@ -3033,8 +3131,10 @@ class Account(
|
||||
|
||||
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
|
||||
|
||||
private fun hasExcessiveHashtags(note: Note): Boolean {
|
||||
val limit = settings.syncedSettings.security.maxHashtagLimit.value
|
||||
fun maxHashtagLimit(): Int = settings.syncedSettings.security.maxHashtagLimit.value
|
||||
|
||||
fun hasExcessiveHashtags(note: Note): Boolean {
|
||||
val limit = maxHashtagLimit()
|
||||
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -245,7 +254,7 @@ class AccountSecurityPreferences(
|
||||
val warnAboutPostsWithReports: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val reportWarningThreshold: MutableStateFlow<Int> = MutableStateFlow(DefaultReportWarningThreshold),
|
||||
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
|
||||
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(8),
|
||||
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val addClientTag: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
) {
|
||||
|
||||
@@ -38,6 +38,7 @@ val DefaultReactions =
|
||||
)
|
||||
|
||||
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
|
||||
val DefaultOnchainZapAmounts = listOf(10_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,
|
||||
)
|
||||
|
||||
@@ -159,7 +171,7 @@ class AccountSecurityPreferencesInternal(
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
val reportWarningThreshold: Int = DefaultReportWarningThreshold,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
val maxHashtagLimit: Int = 5,
|
||||
val maxHashtagLimit: Int = 8,
|
||||
var sendKind0EventsToLocalRelay: Boolean = false,
|
||||
var addClientTag: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -189,6 +189,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
|
||||
@@ -245,6 +249,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
|
||||
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
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
@@ -291,6 +299,25 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
val paymentTracker = NwcPaymentTracker()
|
||||
|
||||
/**
|
||||
* Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps
|
||||
* against the actual on-chain transaction. `null` disables verification (incoming
|
||||
* onchain zap events still get cached, but no `Note.zapsAmount` contribution).
|
||||
* Set during account init.
|
||||
*/
|
||||
@Volatile
|
||||
var onchainBackend: OnchainBackend? = null
|
||||
|
||||
/**
|
||||
* 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()
|
||||
@@ -909,6 +936,18 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
(event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList())
|
||||
}
|
||||
|
||||
is OnchainZapEvent -> {
|
||||
// NIP-BC zaps can target an event id (e), an addressable event (a),
|
||||
// or just the recipient profile (p). Profile-only zaps have no
|
||||
// Note target and are surfaced through profile zap queries.
|
||||
buildList {
|
||||
event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } }
|
||||
event.zappedAddress()?.let { coord ->
|
||||
Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapRequestEvent -> {
|
||||
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
@@ -1684,37 +1723,177 @@ 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(
|
||||
event: OnchainZapEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) return false
|
||||
|
||||
if (!(wasVerified || justVerify(event))) return false
|
||||
|
||||
// Anti-spoofing: NIP-BC requires rejecting self-zaps.
|
||||
val recipient = event.recipient() ?: return false
|
||||
if (event.pubKey.equals(recipient, ignoreCase = true)) return false
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = computeReplyTo(event)
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
refreshNewNoteObservers(note)
|
||||
|
||||
// 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)
|
||||
|
||||
Amethyst.instance.applicationIOScope.launch {
|
||||
try {
|
||||
when (val result = verifier.verify(event)) {
|
||||
is VerifiedOnchainZap.Confirmed -> {
|
||||
repliesTo.forEach {
|
||||
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = true)
|
||||
}
|
||||
}
|
||||
|
||||
is VerifiedOnchainZap.Pending -> {
|
||||
repliesTo.forEach {
|
||||
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = false)
|
||||
}
|
||||
}
|
||||
|
||||
is VerifiedOnchainZap.Rejected -> {
|
||||
Log.d("OnchainZap") {
|
||||
"rejected ${result.txid}: ${result.reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w("OnchainZap", "verification failed for ${event.id}", t)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun attachZapToLiveActivityChannel(
|
||||
@@ -3170,6 +3349,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is OnchainZapEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is NIP90StatusEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.nip03Timestamp
|
||||
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
|
||||
|
||||
/**
|
||||
* Picks the Bitcoin block-explorer (Esplora) base URL.
|
||||
*
|
||||
* This is the single source of truth shared by two features that both talk to
|
||||
* the same Esplora API:
|
||||
* - OpenTimestamps verification ([TorAwareOkHttpOtsResolverBuilder]).
|
||||
* - NIP-BC onchain zaps (the `EsploraBackend` wired in `AppModules`).
|
||||
*
|
||||
* So a user who configures a custom explorer in the OTS settings gets it for
|
||||
* onchain zaps too, and both honour the same Tor preference: a configured
|
||||
* [OtsSettings] custom URL always wins; otherwise mempool.space is used when
|
||||
* Tor is active (it is reachable over Tor) and blockstream.info when it is not.
|
||||
*/
|
||||
object BitcoinExplorerEndpoint {
|
||||
/**
|
||||
* @param customExplorerUrl A user-configured explorer base URL, or null/blank
|
||||
* for the automatic Tor-aware default. Typically
|
||||
* `OtsSettings.normalizedUrl()`.
|
||||
* @param usingTor Whether Bitcoin/"money" traffic is currently routed over Tor.
|
||||
*/
|
||||
fun resolve(
|
||||
customExplorerUrl: String?,
|
||||
usingTor: Boolean,
|
||||
): String =
|
||||
customExplorerUrl?.takeIf { it.isNotBlank() }
|
||||
?: if (usingTor) {
|
||||
OkHttpBitcoinExplorer.MEMPOOL_API_URL
|
||||
} else {
|
||||
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [resolve] but with any trailing slash stripped, for callers that
|
||||
* join request paths with a leading `/` (e.g. `"$base/tx/$txid"`).
|
||||
*/
|
||||
fun resolveNormalized(
|
||||
customExplorerUrl: String?,
|
||||
usingTor: Boolean,
|
||||
): String = resolve(customExplorerUrl, usingTor).trimEnd('/')
|
||||
}
|
||||
@@ -33,13 +33,7 @@ class TorAwareOkHttpOtsResolverBuilder(
|
||||
val cache: OtsBlockHeightCache,
|
||||
val customExplorerUrl: () -> String? = { null },
|
||||
) : OtsResolverBuilder {
|
||||
fun getAPI(usingTor: Boolean): String =
|
||||
customExplorerUrl()
|
||||
?: if (usingTor) {
|
||||
OkHttpBitcoinExplorer.MEMPOOL_API_URL
|
||||
} else {
|
||||
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
|
||||
}
|
||||
fun getAPI(usingTor: Boolean): String = BitcoinExplorerEndpoint.resolve(customExplorerUrl(), usingTor)
|
||||
|
||||
override fun build(): OtsResolver =
|
||||
OtsResolver(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -116,6 +116,7 @@ class UiSharedPreferences(
|
||||
val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations")
|
||||
val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed")
|
||||
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
|
||||
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
|
||||
|
||||
suspend fun uiPreferences(context: Context): UiSettings? =
|
||||
try {
|
||||
@@ -150,6 +151,7 @@ class UiSharedPreferences(
|
||||
showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true,
|
||||
showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true,
|
||||
showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true,
|
||||
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -197,6 +199,7 @@ class UiSharedPreferences(
|
||||
preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations
|
||||
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
|
||||
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
|
||||
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
@@ -43,6 +45,8 @@ class TrustedRelayListsState(
|
||||
val localRelayList: LocalRelayListState,
|
||||
val dmRelayList: DmRelayListState,
|
||||
val searchRelayListState: SearchRelayListState,
|
||||
val indexerRelayList: IndexerRelayListState,
|
||||
val proxyRelayList: ProxyRelayListState,
|
||||
val trustedRelayList: TrustedRelayListState,
|
||||
val broadcastRelayList: BroadcastRelayListState,
|
||||
val scope: CoroutineScope,
|
||||
@@ -57,6 +61,8 @@ class TrustedRelayListsState(
|
||||
localRelayList.flow,
|
||||
dmRelayList.flow,
|
||||
searchRelayListState.flowNoDefaults,
|
||||
indexerRelayList.flowNoDefaults,
|
||||
proxyRelayList.flow,
|
||||
trustedRelayList.flow,
|
||||
broadcastRelayList.flow,
|
||||
),
|
||||
@@ -70,6 +76,8 @@ class TrustedRelayListsState(
|
||||
localRelayList.flow.value,
|
||||
dmRelayList.flow.value,
|
||||
searchRelayListState.flowNoDefaults.value,
|
||||
indexerRelayList.flowNoDefaults.value,
|
||||
proxyRelayList.flow.value,
|
||||
trustedRelayList.flow.value,
|
||||
broadcastRelayList.flow.value,
|
||||
),
|
||||
@@ -86,6 +94,8 @@ class TrustedRelayListsState(
|
||||
localRelayList.flow.value,
|
||||
dmRelayList.flow.value,
|
||||
searchRelayListState.flowNoDefaults.value,
|
||||
indexerRelayList.flowNoDefaults.value,
|
||||
proxyRelayList.flow.value,
|
||||
trustedRelayList.flow.value,
|
||||
broadcastRelayList.flow.value,
|
||||
),
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.mapNotNullAsync
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -178,7 +179,7 @@ class ZapPaymentHandler(
|
||||
|
||||
onProgress(0.02f)
|
||||
|
||||
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend)
|
||||
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats)
|
||||
|
||||
if (splitZapRequests.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
@@ -237,8 +238,10 @@ class ZapPaymentHandler(
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapsToSend: List<MyZapSplitSetup>,
|
||||
): List<ZapRequestReady> =
|
||||
mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
|
||||
totalAmountMilliSats: Long,
|
||||
): List<ZapRequestReady> {
|
||||
val totalWeight = zapsToSend.sumOf { it.weight }
|
||||
return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
|
||||
// makes sure the author receives the zap event
|
||||
val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
|
||||
|
||||
@@ -247,15 +250,31 @@ class ZapPaymentHandler(
|
||||
|
||||
val noteEvent = note.event
|
||||
|
||||
// Per NIP-57 §6: the zap request SHOULD carry `amount` (in msats) and
|
||||
// `lnurl` so the recipient's LNURL provider — and clients reading the
|
||||
// resulting receipt — can validate them under Appendix F.
|
||||
val splitAmount = calculateZapValue(totalAmountMilliSats, next.weight, totalWeight)
|
||||
val splitLnurl = LnurlForm.toUrl(next.lnAddress)?.let(LnurlForm::urlToBech32)
|
||||
|
||||
val zapRequest =
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
|
||||
account.createZapRequestFor(noteEvent, pollOption, message, zapType, next.user, userRelayList + authorRelayList)
|
||||
account.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
toUser = next.user,
|
||||
additionalRelays = userRelayList + authorRelayList,
|
||||
amountMillisats = splitAmount,
|
||||
lnurl = splitLnurl,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
ZapRequestReady(next, zapRequest)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assembleAllInvoices(
|
||||
requests: List<ZapRequestReady>,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
|
||||
* to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier]
|
||||
* so the two notification surfaces stay consistent.
|
||||
*/
|
||||
object CalendarReminderNotifier {
|
||||
@Volatile
|
||||
private var channel: NotificationChannel? = null
|
||||
private const val REMINDER_NOT_ID_BASE = 0x80000
|
||||
|
||||
/**
|
||||
* @param eventId the appointment's event id — used to derive a stable notification id so a
|
||||
* second reminder for the same event collapses rather than stacking.
|
||||
* @param title the appointment title (or a fallback string).
|
||||
* @param body a short pre-formatted body, e.g. "Starts in 15 minutes".
|
||||
* @param deepLink a `nostr:naddr…` URI for the calendar event. Tapping the notification
|
||||
* hands this to MainActivity, which routes it via `uriToRoute` → the
|
||||
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
|
||||
* branches in RouteMaker resolve to Route.CalendarEventDetail).
|
||||
*/
|
||||
fun notifyReminder(
|
||||
context: Context,
|
||||
eventId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
deepLink: String,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
val notId = idFor(eventId)
|
||||
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
|
||||
val tapIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = android.net.Uri.parse(deepLink)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
val tapPendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
notId,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
// POST_NOTIFICATIONS is runtime-granted on Android 13+; bail when denied so the lint
|
||||
// call below doesn't flag and we don't log a misleading no-op.
|
||||
if (!nm.areNotificationsEnabled()) return
|
||||
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(tapPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
try {
|
||||
nm.notify(notId, notification)
|
||||
} catch (_: SecurityException) {
|
||||
// Race: permission revoked between the check above and notify().
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
stringRes(context, R.string.calendar_reminder_channel_id),
|
||||
stringRes(context, R.string.calendar_reminder_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = stringRes(context, R.string.calendar_reminder_channel_description)
|
||||
}
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel!!)
|
||||
}
|
||||
|
||||
// Distinct id per event so two reminders for the same event collapse onto one row while
|
||||
// separate events render side by side.
|
||||
private fun idFor(eventId: String): Int = REMINDER_NOT_ID_BASE xor eventId.hashCode()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Device-wide preferences for the calendar reminder worker.
|
||||
*
|
||||
* Stored at device scope (rather than per-account) because the worker that consults them runs
|
||||
* globally — multiplexing per-account preferences would require account-context plumbing into
|
||||
* WorkManager that the rest of the app doesn't have. A user who flips between two accounts on
|
||||
* the same device shares the same lead-time and enabled-state. Per-account preferences could be
|
||||
* a follow-up if anyone asks.
|
||||
*/
|
||||
class CalendarReminderPrefs(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
|
||||
|
||||
fun setLeadMinutes(minutes: Int) {
|
||||
prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_LEAD_MINUTES = 15
|
||||
const val DEFAULT_ENABLED = true
|
||||
|
||||
// Choices presented in the settings UI. Anchored to the worker cadence — lead times
|
||||
// smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the
|
||||
// UX shape supports without an extra "hours" picker.
|
||||
val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60)
|
||||
|
||||
private const val PREF_NAME = "amethyst_calendar_reminder_prefs"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_LEAD_MINUTES = "lead_minutes"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
|
||||
* the worker that consults it runs in the app process and the dataset is tiny (≤ a few hundred
|
||||
* IDs at most). Without persistence, every worker run after a restart would re-notify for the
|
||||
* same upcoming event until it started, since LocalCache has no memory of past reminders.
|
||||
*
|
||||
* Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only
|
||||
* presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just
|
||||
* fired so the store doesn't grow unbounded over time.
|
||||
*/
|
||||
class CalendarReminderStore(
|
||||
context: Context,
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Returns true when we've previously notified for this exact event-start pairing. If the
|
||||
* author updates the appointment to a new start time, the stored value won't match and
|
||||
* we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved
|
||||
* meeting shouldn't be silently skipped.
|
||||
*/
|
||||
fun wasNotified(
|
||||
eventId: String,
|
||||
eventStartSeconds: Long,
|
||||
): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds
|
||||
|
||||
fun markNotified(
|
||||
eventId: String,
|
||||
eventStartSeconds: Long,
|
||||
) {
|
||||
prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called
|
||||
* after each worker run so the store stays bounded — events that have long since ended
|
||||
* can't fire a second reminder, so their entries are dead weight.
|
||||
*/
|
||||
fun forgetBefore(cutoffSeconds: Long) {
|
||||
val editor = prefs.edit()
|
||||
var changed = false
|
||||
prefs.all.forEach { (key, value) ->
|
||||
if (value is Long && value < cutoffSeconds) {
|
||||
editor.remove(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) editor.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_NAME = "amethyst_calendar_reminders"
|
||||
private const val KEY_PREFIX = "notified:"
|
||||
|
||||
private fun keyFor(eventId: String) = KEY_PREFIX + eventId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.calendar
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Periodic scan that posts "starting soon" notifications for appointments the user has RSVP'd
|
||||
* to as ACCEPTED.
|
||||
*
|
||||
* The work is bounded — scans LocalCache (which is bounded by the relay subscription) and
|
||||
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
|
||||
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
|
||||
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
|
||||
*/
|
||||
class CalendarReminderWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val prefs = CalendarReminderPrefs(applicationContext)
|
||||
if (!prefs.isEnabled()) {
|
||||
Log.d(TAG) { "Reminders disabled; skipping scan." }
|
||||
return Result.success()
|
||||
}
|
||||
val now = TimeUtils.now()
|
||||
val windowEnd = now + prefs.leadMinutes() * 60L
|
||||
val store = CalendarReminderStore(applicationContext)
|
||||
|
||||
// Walk every kind-31925 RSVP authored by an account on this device. We don't have a
|
||||
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
|
||||
// present in cache — the alternative (looking only at the foreground account) would
|
||||
// silently break notifications for account switching during the lead window.
|
||||
val acceptedRsvps =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
|
||||
|
||||
acceptedRsvps.forEach { rsvp ->
|
||||
val targetAddress = rsvp.calendarEventAddress() ?: return@forEach
|
||||
val targetNote = LocalCache.addressables.get(targetAddress) ?: return@forEach
|
||||
val view = targetNote.appointmentView() ?: return@forEach
|
||||
val start = view.startSeconds ?: return@forEach
|
||||
val eventId =
|
||||
(targetNote.event as? CalendarTimeSlotEvent)?.id
|
||||
?: (targetNote.event as? CalendarDateSlotEvent)?.id
|
||||
?: return@forEach
|
||||
|
||||
if (start !in now..windowEnd) return@forEach
|
||||
// Keyed on (eventId, start) so a moved appointment re-fires when the new start
|
||||
// enters the lead window — the old notification stays valid in the system tray.
|
||||
if (store.wasNotified(eventId, start)) return@forEach
|
||||
|
||||
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
|
||||
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
|
||||
val body =
|
||||
pluralStringRes(
|
||||
applicationContext,
|
||||
R.plurals.calendar_reminder_body,
|
||||
minutesAway,
|
||||
minutesAway,
|
||||
)
|
||||
val deepLink =
|
||||
"nostr:" +
|
||||
com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
.create(targetAddress.kind, targetAddress.pubKeyHex, targetAddress.dTag, null)
|
||||
CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body, deepLink)
|
||||
store.markNotified(eventId, start)
|
||||
Log.d(TAG) { "Notified $eventId (starts in ${minutesAway}m)" }
|
||||
}
|
||||
|
||||
// Prune entries for events that ended more than a day ago — they can't fire again.
|
||||
store.forgetBefore(now - PRUNE_AGE_SECONDS)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CalendarReminderWorker"
|
||||
private const val WORK_NAME = "calendar_reminder_worker"
|
||||
|
||||
// Don't bother remembering "I notified for this" entries for events whose start was
|
||||
// more than a day ago; they can't fire again so the entry is pure overhead.
|
||||
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
Log.d(TAG) { "schedule(): enqueueUniquePeriodicWork($WORK_NAME, 15 MIN, KEEP)" }
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ import kotlinx.coroutines.withContext
|
||||
*/
|
||||
object CallNotifier {
|
||||
private var callChannel: NotificationChannel? = null
|
||||
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
|
||||
const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
|
||||
private const val CALL_NOTIFICATION_ID = 0x50000
|
||||
|
||||
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
|
||||
|
||||
@@ -24,7 +24,10 @@ import android.os.Build
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
|
||||
class ReportAssembler {
|
||||
fun buildReport(e: Throwable): String =
|
||||
fun buildReport(
|
||||
e: Throwable,
|
||||
threadName: String = Thread.currentThread().name,
|
||||
): String =
|
||||
buildString {
|
||||
append(e.javaClass.simpleName)
|
||||
append(": ")
|
||||
@@ -70,9 +73,14 @@ class ReportAssembler {
|
||||
append("| User | ")
|
||||
append(Build.USER)
|
||||
appendLine(" |")
|
||||
append("| Thread | ")
|
||||
append(threadName)
|
||||
appendLine(" |")
|
||||
appendLine()
|
||||
|
||||
appendLine("```")
|
||||
append("Thread: ")
|
||||
appendLine(threadName)
|
||||
appendLine(e.toString())
|
||||
e.stackTrace.forEach {
|
||||
append(" ")
|
||||
|
||||
@@ -35,8 +35,9 @@ class UnexpectedCrashSaver(
|
||||
) {
|
||||
if (e !is OutOfMemoryError) {
|
||||
// OOM reports are junk
|
||||
val threadName = t.name
|
||||
scope.launch {
|
||||
cache.writeReport(ReportAssembler().buildReport(e))
|
||||
cache.writeReport(ReportAssembler().buildReport(e, threadName))
|
||||
}
|
||||
}
|
||||
defaultUEH!!.uncaughtException(t, e)
|
||||
|
||||
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.lightning.Lud06
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache
|
||||
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -210,6 +212,8 @@ class LightningAddressResolver {
|
||||
): String {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
val lnurlpUrl = assembleUrl(lnAddress)
|
||||
|
||||
val lnAddressJson =
|
||||
fetchLightningAddressJson(
|
||||
lnAddress,
|
||||
@@ -248,6 +252,17 @@ class LightningAddressResolver {
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false
|
||||
val nostrPubkey = lnurlp.get("nostrPubkey")?.asText()?.ifBlank { null }
|
||||
|
||||
// Prime the receipt-validation cache so incoming zap receipts can be
|
||||
// checked against this provider's nostrPubkey (NIP-57 Appendix F)
|
||||
// without re-fetching the lnurlp endpoint.
|
||||
if (lnurlpUrl != null) {
|
||||
LnurlEndpointCache.put(
|
||||
lnurlpUrl,
|
||||
LnurlEndpointInfo(nostrPubkey = nostrPubkey, allowsNostr = allowsNostr),
|
||||
)
|
||||
}
|
||||
|
||||
val invoice =
|
||||
fetchLightningInvoice(
|
||||
|
||||
@@ -32,6 +32,14 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
object CachedReversedGeoLocations {
|
||||
val locationNames = LruCache<String, String>(20)
|
||||
|
||||
// Geocoder.isPresent() reflects whether the system has a backing
|
||||
// IGeocodeProvider registered — fixed at boot, safe to memoize. On AOSP /
|
||||
// GrapheneOS without Google Play Services or microG configured no provider
|
||||
// is registered and this returns false.
|
||||
private val geocoderAvailable: Boolean by lazy { Geocoder.isPresent() }
|
||||
|
||||
fun isGeocoderAvailable(): Boolean = geocoderAvailable
|
||||
|
||||
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
|
||||
|
||||
fun geoLocate(
|
||||
@@ -42,31 +50,24 @@ object CachedReversedGeoLocations {
|
||||
) {
|
||||
locationNames[geoHashStr]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
if (Geocoder.isPresent()) {
|
||||
ReverseGeolocation.execute(location, context) { cityNames ->
|
||||
if (cityNames != null) {
|
||||
val cityName =
|
||||
cityNames.firstNotNullOfOrNull {
|
||||
val name = it.toCityCountry()
|
||||
if (!name.isBlank()) {
|
||||
name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (cityName != null) {
|
||||
locationNames.put(geoHashStr, cityName)
|
||||
onReady(cityName)
|
||||
}
|
||||
} else {
|
||||
// error
|
||||
onReady(null)
|
||||
if (!geocoderAvailable) {
|
||||
Log.d("ReverseGeoLocation") { "Geocoder service not present on this device" }
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
ReverseGeolocation.execute(location, context) { cityNames ->
|
||||
val cityName =
|
||||
cityNames?.firstNotNullOfOrNull {
|
||||
it.toCityCountry().takeIf { name -> name.isNotBlank() }
|
||||
}
|
||||
if (cityName != null) {
|
||||
locationNames.put(geoHashStr, cityName)
|
||||
}
|
||||
} else {
|
||||
Log.d("ReverseGeoLocation", "Geocoder not present")
|
||||
onReady(cityName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
@@ -56,6 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
@@ -147,15 +149,34 @@ class EventNotificationConsumer(
|
||||
|
||||
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
|
||||
|
||||
// Ask the event which of our signing accounts it's notifying.
|
||||
// Each kind declares its own notification semantics in
|
||||
// [Event.notifies] (lowercase `p` by default, NIP-22 also checks
|
||||
// uppercase `P`, etc.), so we don't hard-code tag names here.
|
||||
// Match the in-app Notifications feed exactly: the event must
|
||||
// p-tag the account AND pass NotificationFeedFilter.tagsAnEventByUser
|
||||
// — the latter is the per-kind "is this actually for me" rule
|
||||
// (reply-to-me, citation of my post, fork of my content, community
|
||||
// moderation, reaction targeting my note, etc.). WakeUpEvent is the
|
||||
// one exception: its [Event.notifies] is `true` unconditionally so
|
||||
// every signed-in account processes it (the dispatcher bypasses
|
||||
// the same way).
|
||||
//
|
||||
// One LocalCache lookup per event regardless of account count —
|
||||
// the note is the same for every saved account. Use the Event
|
||||
// overload so AddressableEvent kinds resolve to their replaceable
|
||||
// note (id-keyed version has empty replyTo after insertion).
|
||||
val matchingNote: Note? =
|
||||
if (event is WakeUpEvent) {
|
||||
null
|
||||
} else {
|
||||
LocalCache.getNoteIfExists(event) ?: return@withWakeLock
|
||||
}
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach { savedAccount ->
|
||||
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
|
||||
|
||||
val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach
|
||||
if (!event.notifies(accountHex)) return@forEach
|
||||
if (matchingNote != null) {
|
||||
if (!event.isTaggedUser(accountHex)) return@forEach
|
||||
if (!NotificationFeedFilter.tagsAnEventByUser(matchingNote, accountHex)) return@forEach
|
||||
}
|
||||
|
||||
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
|
||||
try {
|
||||
@@ -204,6 +225,17 @@ class EventNotificationConsumer(
|
||||
// to repeat either check.
|
||||
if (event.pubKey == account.signer.pubKey) return
|
||||
|
||||
// Mirror NotificationFeedFilter: reactions and zaps target a note via
|
||||
// `replyTo`, not via thread-root tags on the wrapper event, so checking
|
||||
// the wrapper's own thread misses them. Resolve the target and drop if
|
||||
// its thread is muted. (The feed extends this to reposts too, but
|
||||
// RepostEvent / GenericRepostEvent aren't routed below — push doesn't
|
||||
// notify on reposts at all today.)
|
||||
if (event is ReactionEvent || event is LnZapEvent) {
|
||||
val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull()
|
||||
if (target != null && account.isThreadMuted(account.resolveThreadRoot(target))) return
|
||||
}
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event, account)
|
||||
|
||||
@@ -554,6 +586,9 @@ class EventNotificationConsumer(
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = account.userProfile().profilePicture(),
|
||||
chatroomMembers = null,
|
||||
marmotNostrGroupId = nostrGroupId,
|
||||
marmotReplyToInnerEventId = innerEvent.id,
|
||||
marmotReplyToInnerAuthor = innerEvent.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Registry of user-facing notification channels and helpers to read their
|
||||
* current importance / open the system settings page for them.
|
||||
*
|
||||
* Android (post-Oreo) owns channel state — the app cannot toggle channel
|
||||
* importance directly. The Notifications settings screen surfaces the
|
||||
* channels here and routes the user to the system per-channel page.
|
||||
*
|
||||
* Foreground-service channels (relay-connection, nests audio) are
|
||||
* intentionally omitted: they're functional indicators, not content
|
||||
* notifications, and disabling them breaks the foreground service contract.
|
||||
*/
|
||||
object NotificationChannels {
|
||||
private const val TAG = "NotificationChannels"
|
||||
|
||||
enum class ChannelStatus { ON, SILENT, OFF }
|
||||
|
||||
/**
|
||||
* A single content-bearing notification channel exposed in the settings UI.
|
||||
* [ensure] creates the channel if missing — needed so the system per-channel
|
||||
* settings page has something to open even before the first notification fires.
|
||||
*/
|
||||
data class Entry(
|
||||
val nameRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val channelId: (Context) -> String,
|
||||
val ensure: (Context) -> Unit,
|
||||
)
|
||||
|
||||
val contentChannels: List<Entry> =
|
||||
listOf(
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_dms_channel_name,
|
||||
icon = MaterialSymbols.Mail,
|
||||
channelId = { stringRes(it, R.string.app_notification_dms_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateDMChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_mentions_channel_name,
|
||||
icon = MaterialSymbols.AlternateEmail,
|
||||
channelId = { stringRes(it, R.string.app_notification_mentions_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateMentionChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_replies_channel_name,
|
||||
icon = MaterialSymbols.Chat,
|
||||
channelId = { stringRes(it, R.string.app_notification_replies_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateReplyChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_reactions_channel_name,
|
||||
icon = MaterialSymbols.Favorite,
|
||||
channelId = { stringRes(it, R.string.app_notification_reactions_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateReactionChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_zaps_channel_name,
|
||||
icon = MaterialSymbols.Bolt,
|
||||
channelId = { stringRes(it, R.string.app_notification_zaps_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateZapChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_chess_channel_name,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
channelId = { stringRes(it, R.string.app_notification_chess_channel_id) },
|
||||
ensure = { NotificationUtils.getOrCreateChessChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_scheduled_posts_channel_name,
|
||||
icon = MaterialSymbols.Schedule,
|
||||
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
|
||||
ensure = { ScheduledPostNotifier.ensureChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_calls_channel_name,
|
||||
icon = MaterialSymbols.Call,
|
||||
channelId = { CallNotifier.CALL_CHANNEL_ID },
|
||||
ensure = { CallNotifier.getOrCreateCallChannel(it) },
|
||||
),
|
||||
)
|
||||
|
||||
fun statusOf(
|
||||
context: Context,
|
||||
channelId: String,
|
||||
): ChannelStatus {
|
||||
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ChannelStatus.OFF
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return ChannelStatus.OFF
|
||||
val channel = nm.getNotificationChannel(channelId) ?: return ChannelStatus.ON
|
||||
return when (channel.importance) {
|
||||
NotificationManager.IMPORTANCE_NONE -> ChannelStatus.OFF
|
||||
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_LOW -> ChannelStatus.SILENT
|
||||
else -> ChannelStatus.ON
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the system per-channel notification settings page. Falls back to
|
||||
* the app-level notification settings if the per-channel intent isn't
|
||||
* supported (e.g. the channel was never created, or on stripped-down ROMs).
|
||||
*/
|
||||
fun openChannelSettings(
|
||||
context: Context,
|
||||
channelId: String,
|
||||
) {
|
||||
try {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Per-channel intent failed, falling back to app notification settings", e)
|
||||
openAppNotificationSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun openAppNotificationSettings(context: Context) {
|
||||
try {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open app notification settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -51,6 +53,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -96,6 +99,7 @@ class NotificationDispatcher(
|
||||
// Direct-arrival
|
||||
PrivateDmEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
TextNoteEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
@@ -171,15 +175,37 @@ class NotificationDispatcher(
|
||||
// matches the downstream per-channel policy. Calls
|
||||
// use a stricter 20s check in notifyIncomingCall so
|
||||
// they still pass through.
|
||||
// - event.notifies(pubkey) for any of our accounts —
|
||||
// each kind decides which tag(s) name its recipients
|
||||
// (lowercase `p` by default, plus uppercase `P` for
|
||||
// NIP-22 root authors, etc.).
|
||||
val predicate = { event: Event ->
|
||||
event.kind in NOTIFICATION_KINDS &&
|
||||
event.createdAt >= dispatcherSince &&
|
||||
event.createdAt >= TimeUtils.fifteenMinutesAgo() &&
|
||||
pubkeys.any { event.notifies(it) }
|
||||
// - WakeUpEvent bypasses recipient matching: its
|
||||
// [Event.notifies] is `true` unconditionally, meaning
|
||||
// "wake every signed-in account" (p-tags on a WakeUp
|
||||
// name referenced-event authors, not recipients).
|
||||
// - Everything else uses isTaggedUser + tagsAnEventByUser
|
||||
// — same pair the in-app Notifications feed uses, so
|
||||
// push and feed agree on what counts as a mention,
|
||||
// reply, citation, fork, or community moderation.
|
||||
// Note: NIP-22 CommentEvents where the user is only
|
||||
// the root author (uppercase `P`) no longer match —
|
||||
// intentional alignment with the feed, which gates
|
||||
// on lowercase `p` only.
|
||||
val predicate = predicate@{ event: Event ->
|
||||
if (event.kind !in NOTIFICATION_KINDS) return@predicate false
|
||||
if (event.createdAt < dispatcherSince) return@predicate false
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return@predicate false
|
||||
if (event is WakeUpEvent) return@predicate true
|
||||
|
||||
// getNoteIfExists(event) — not (event.id) — so
|
||||
// AddressableEvent kinds (LongTextNote, WikiNote,
|
||||
// LiveChess*, VideoHorizontal/Vertical) resolve to
|
||||
// their address-keyed replaceable note. The id-keyed
|
||||
// version note has its replyTo moved away during
|
||||
// insertion (LocalCache.consumeBaseReplaceable), so
|
||||
// tagsAnEventByUser's replyTo check would otherwise
|
||||
// always miss for replies into addressable posts.
|
||||
val note = LocalCache.getNoteIfExists(event) ?: return@predicate false
|
||||
pubkeys.any { pubkey ->
|
||||
event.isTaggedUser(pubkey) &&
|
||||
NotificationFeedFilter.tagsAnEventByUser(note, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache
|
||||
|
||||
@@ -94,6 +94,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
sendPublicReply(accountNpub, targetEventId, replyText)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationUtils.MARMOT_REPLY_ACTION -> {
|
||||
val replyText =
|
||||
RemoteInput
|
||||
.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
|
||||
if (replyText.isNullOrBlank()) return
|
||||
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
val nostrGroupId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_GROUP_ID) ?: return
|
||||
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
|
||||
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +159,34 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
account.sendNip17PrivateMessage(template)
|
||||
}
|
||||
|
||||
private suspend fun sendMarmotReply(
|
||||
accountNpub: String,
|
||||
nostrGroupId: String,
|
||||
replyToInnerEventId: String?,
|
||||
replyToInnerAuthor: String?,
|
||||
replyText: String,
|
||||
) {
|
||||
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
|
||||
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
|
||||
|
||||
val manager = account.marmotManager ?: return
|
||||
|
||||
// Use id+author from the Intent so the reply is threaded even when
|
||||
// LocalCache hasn't been rehydrated yet (cold-process broadcast
|
||||
// receiver: Account.restoreAll runs async on init and may not have
|
||||
// finished by the time we get here).
|
||||
val bundle =
|
||||
manager.buildTextMessage(
|
||||
nostrGroupId = nostrGroupId,
|
||||
text = replyText,
|
||||
replyToEventId = replyToInnerEventId,
|
||||
replyToAuthorPubKey = replyToInnerAuthor,
|
||||
persistOwn = false,
|
||||
)
|
||||
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
|
||||
}
|
||||
|
||||
private suspend fun sendPublicReply(
|
||||
accountNpub: String,
|
||||
targetEventId: String,
|
||||
|
||||
@@ -60,12 +60,16 @@ object NotificationUtils {
|
||||
|
||||
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
|
||||
const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION"
|
||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||
const val KEY_REPLY_TEXT = "key_reply_text"
|
||||
const val KEY_NOTIFICATION_ID = "key_notification_id"
|
||||
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
||||
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
||||
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
|
||||
const val KEY_MARMOT_GROUP_ID = "key_marmot_group_id"
|
||||
const val KEY_MARMOT_REPLY_TO_INNER_ID = "key_marmot_reply_to_inner_id"
|
||||
const val KEY_MARMOT_REPLY_TO_INNER_AUTHOR = "key_marmot_reply_to_inner_author"
|
||||
|
||||
private const val DM_SUMMARY_ID = 0x10000
|
||||
private const val ZAP_SUMMARY_ID = 0x20000
|
||||
@@ -374,6 +378,9 @@ object NotificationUtils {
|
||||
accountNpub: String? = null,
|
||||
accountPictureUrl: String? = null,
|
||||
chatroomMembers: String? = null,
|
||||
marmotNostrGroupId: String? = null,
|
||||
marmotReplyToInnerEventId: String? = null,
|
||||
marmotReplyToInnerAuthor: String? = null,
|
||||
) {
|
||||
getOrCreateDMChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
|
||||
@@ -390,6 +397,9 @@ object NotificationUtils {
|
||||
accountNpub = accountNpub,
|
||||
accountPictureUrl = accountPictureUrl,
|
||||
chatroomMembers = chatroomMembers,
|
||||
marmotNostrGroupId = marmotNostrGroupId,
|
||||
marmotReplyToInnerEventId = marmotReplyToInnerEventId,
|
||||
marmotReplyToInnerAuthor = marmotReplyToInnerAuthor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,6 +435,9 @@ object NotificationUtils {
|
||||
accountNpub: String?,
|
||||
accountPictureUrl: String?,
|
||||
chatroomMembers: String?,
|
||||
marmotNostrGroupId: String? = null,
|
||||
marmotReplyToInnerEventId: String? = null,
|
||||
marmotReplyToInnerAuthor: String? = null,
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
@@ -522,6 +535,50 @@ object NotificationUtils {
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
|
||||
builder.addAction(replyAction)
|
||||
} else if (accountNpub != null && marmotNostrGroupId != null) {
|
||||
// Marmot/MLS Reply action: sends the user's text as an encrypted
|
||||
// kind:9 inside the Marmot group, replying to the inner event
|
||||
// that triggered this notification. Mirrors the NIP-17 path
|
||||
// above but routes through NotificationReplyReceiver's
|
||||
// MARMOT_REPLY_ACTION branch so we never publish a plaintext
|
||||
// public reply for an encrypted group message.
|
||||
val remoteInput =
|
||||
RemoteInput
|
||||
.Builder(KEY_REPLY_TEXT)
|
||||
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
|
||||
.build()
|
||||
|
||||
val replyIntent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = MARMOT_REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, accountNpub)
|
||||
putExtra(KEY_MARMOT_GROUP_ID, marmotNostrGroupId)
|
||||
if (marmotReplyToInnerEventId != null) {
|
||||
putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, marmotReplyToInnerEventId)
|
||||
}
|
||||
if (marmotReplyToInnerAuthor != null) {
|
||||
putExtra(KEY_MARMOT_REPLY_TO_INNER_AUTHOR, marmotReplyToInnerAuthor)
|
||||
}
|
||||
}
|
||||
|
||||
val replyPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
notId,
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val replyAction =
|
||||
NotificationCompat.Action
|
||||
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
|
||||
builder.addAction(replyAction)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class SurgeDnsStore(
|
||||
readRecords(file)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" }
|
||||
file.delete()
|
||||
if (!file.delete()) Log.w(TAG) { "Failed to delete corrupt DNS cache blob at ${file.path}" }
|
||||
return
|
||||
}
|
||||
// restore() uses putIfAbsent and never marks dirty, so we deliberately do NOT clear the
|
||||
@@ -104,8 +104,8 @@ class SurgeDnsStore(
|
||||
file.parentFile?.mkdirs()
|
||||
writeRecords(tmp, records)
|
||||
if (!tmp.renameTo(file)) {
|
||||
file.delete()
|
||||
if (!tmp.renameTo(file)) {
|
||||
// If delete fails the second rename will fail too; skip straight to the copy fallback.
|
||||
if (!file.delete() || !tmp.renameTo(file)) {
|
||||
tmp.copyTo(file, overwrite = true)
|
||||
}
|
||||
}
|
||||
@@ -117,13 +117,13 @@ class SurgeDnsStore(
|
||||
} finally {
|
||||
// Cleans up after both happy paths (copyTo fallback) and failure paths (writeRecords
|
||||
// crashed partway, leaving a partial blob) so a corrupt tmp can't accumulate.
|
||||
if (tmp.exists()) tmp.delete()
|
||||
if (tmp.exists() && !tmp.delete()) Log.w(TAG) { "Failed to delete DNS cache tmp file at ${tmp.path}" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */
|
||||
fun clear() {
|
||||
file.delete()
|
||||
if (file.exists() && !file.delete()) Log.w(TAG) { "Failed to clear DNS cache blob at ${file.path}" }
|
||||
}
|
||||
|
||||
private fun writeRecords(
|
||||
|
||||
@@ -118,6 +118,37 @@ fun ControlWhenPlayerIsActive(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses [mediaControllerState] when the host activity leaves the foreground.
|
||||
*
|
||||
* Standalone version of the ON_PAUSE arm of [ControlWhenPlayerIsActive] for
|
||||
* callers (e.g. voice notes) that don't have the visibility mutex / auto-resume
|
||||
* logic but still need to stop playback when the app backgrounds. Pairs with
|
||||
* the 30s release timer in [GetVideoController] — pause immediately, release
|
||||
* the controller after the timeout, reassemble on resume.
|
||||
*
|
||||
* Skips the explicit BackgroundMedia (PiP) instance: that one is opted in to
|
||||
* keep-playing.
|
||||
*/
|
||||
@Composable
|
||||
fun PauseControllerWhenInBackground(mediaControllerState: MediaControllerState) {
|
||||
val controller = mediaControllerState.controller
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, mediaControllerState) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE &&
|
||||
controller.isPlaying &&
|
||||
!BackgroundMedia.isMutex(mediaControllerState)
|
||||
) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerEventListener(
|
||||
val view: View,
|
||||
) : Player.Listener {
|
||||
|
||||
@@ -21,74 +21,172 @@
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal const val BACKGROUND_RELEASE_TIMEOUT_MS = 30_000L
|
||||
|
||||
@Composable
|
||||
fun GetVideoController(
|
||||
mediaItem: LoadedMediaItem,
|
||||
muted: Boolean = false,
|
||||
play: Boolean = false,
|
||||
// Opt-out for callers whose lifecycle owner is already a background-playback
|
||||
// surface (PiP): there, the 30s timer would fire as soon as the activity
|
||||
// enters PiP mode and race the controller build / `RegisterBackgroundMedia`
|
||||
// registration, killing the just-attached controller and blanking the
|
||||
// window. The opt-out skips the timer entirely for those callers.
|
||||
releaseOnBackgroundTimeout: Boolean = true,
|
||||
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controllerState by remember(mediaItem) {
|
||||
PlaybackServiceClient
|
||||
.controllerAsFlow(
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
proxyPort = mediaItem.src.proxyPort,
|
||||
keepPlaying = mediaItem.src.keepPlaying,
|
||||
context = context,
|
||||
).onEach { state ->
|
||||
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
|
||||
|
||||
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
|
||||
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
|
||||
// Read first and only push an IPC if the value actually needs to change —
|
||||
// with several feed videos preloading at once each volume= write was a
|
||||
// round-trip to the service for nothing.
|
||||
val targetVolume =
|
||||
when {
|
||||
BackgroundMedia.isPlaying() -> 0f
|
||||
muted -> 0f
|
||||
else -> 1f
|
||||
// After the app has been in the background for BACKGROUND_RELEASE_TIMEOUT_MS,
|
||||
// drop the MediaController so the underlying ExoPlayer + codec/buffer can be
|
||||
// returned to the pool. On resume the flow is rebuilt, the new session reuses
|
||||
// the same paused player from the warm pool (keyed by URI), and the onEach
|
||||
// warm-pool fast path keeps position and buffered data intact.
|
||||
val keepAlive = remember { mutableStateOf(true) }
|
||||
|
||||
val controllerState by remember(mediaItem, keepAlive.value) {
|
||||
if (keepAlive.value) {
|
||||
PlaybackServiceClient
|
||||
.controllerAsFlow(
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
proxyPort = mediaItem.src.proxyPort,
|
||||
keepPlaying = mediaItem.src.keepPlaying,
|
||||
context = context,
|
||||
).onEach { state ->
|
||||
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
|
||||
|
||||
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
|
||||
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
|
||||
// Read first and only push an IPC if the value actually needs to change —
|
||||
// with several feed videos preloading at once each volume= write was a
|
||||
// round-trip to the service for nothing.
|
||||
val targetVolume =
|
||||
when {
|
||||
BackgroundMedia.isPlaying() -> 0f
|
||||
muted -> 0f
|
||||
else -> 1f
|
||||
}
|
||||
if (state.controller.volume != targetVolume) {
|
||||
state.controller.volume = targetVolume
|
||||
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
|
||||
}
|
||||
if (state.controller.volume != targetVolume) {
|
||||
state.controller.volume = targetVolume
|
||||
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
|
||||
}
|
||||
|
||||
if (play) {
|
||||
state.controller.playWhenReady = true
|
||||
}
|
||||
if (play) {
|
||||
state.controller.playWhenReady = true
|
||||
}
|
||||
|
||||
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
|
||||
// buffer for this exact MediaItem, the MediaController's local mirror already
|
||||
// shows the matching mediaId. Calling setMediaItem in that case would reset the
|
||||
// player and discard the buffer — exactly what the warm pool exists to avoid.
|
||||
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
|
||||
// to cold and resurfaced, or hit an error before we attached).
|
||||
val targetMediaId = mediaItem.item.mediaId
|
||||
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
|
||||
if (needsLoad) {
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
} else if (state.controller.playbackState == Player.STATE_IDLE) {
|
||||
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
|
||||
state.controller.prepare()
|
||||
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
|
||||
// buffer for this exact MediaItem, the MediaController's local mirror already
|
||||
// shows the matching mediaId. Calling setMediaItem in that case would reset the
|
||||
// player and discard the buffer — exactly what the warm pool exists to avoid.
|
||||
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
|
||||
// to cold and resurfaced, or hit an error before we attached).
|
||||
val targetMediaId = mediaItem.item.mediaId
|
||||
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
|
||||
if (needsLoad) {
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
} else if (state.controller.playbackState == Player.STATE_IDLE) {
|
||||
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
|
||||
state.controller.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flowOf<MediaControllerState?>(null)
|
||||
}
|
||||
}.collectAsState(null)
|
||||
|
||||
if (releaseOnBackgroundTimeout) {
|
||||
ReleaseControllerWhenBackgroundedFor(
|
||||
timeoutMs = BACKGROUND_RELEASE_TIMEOUT_MS,
|
||||
controllerState = controllerState,
|
||||
keepAlive = keepAlive,
|
||||
)
|
||||
}
|
||||
|
||||
controllerState?.let {
|
||||
inner(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips [keepAlive] to `false` after the host activity has been at ON_PAUSE for
|
||||
* [timeoutMs], so the gated flow upstream releases the MediaController. ON_RESUME
|
||||
* cancels any pending timer and flips it back to `true` so the controller is
|
||||
* reacquired.
|
||||
*
|
||||
* The BackgroundMedia (PiP) controller is exempt — it's opted into background
|
||||
* playback and must keep its MediaController alive past the timeout.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReleaseControllerWhenBackgroundedFor(
|
||||
timeoutMs: Long,
|
||||
controllerState: MediaControllerState?,
|
||||
keepAlive: MutableState<Boolean>,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentControllerState by rememberUpdatedState(controllerState)
|
||||
|
||||
DisposableEffect(lifecycleOwner, keepAlive) {
|
||||
val scope = CoroutineScope(Dispatchers.Main)
|
||||
var timeoutJob: Job? = null
|
||||
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob =
|
||||
scope.launch {
|
||||
delay(timeoutMs)
|
||||
val cs = currentControllerState
|
||||
if (cs == null || !BackgroundMedia.isMutex(cs)) {
|
||||
keepAlive.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
keepAlive.value = true
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,13 @@ class PipVideoActivity : ComponentActivity() {
|
||||
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
|
||||
|
||||
GetMediaItem(mediaItemData) { mediaItem ->
|
||||
GetVideoController(mediaItem, muted, true) { controllerState ->
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
muted = muted,
|
||||
play = true,
|
||||
// PiP IS the opt-in for background playback — never release on background.
|
||||
releaseOnBackgroundTimeout = false,
|
||||
) { controllerState ->
|
||||
// PiP window is small, keep bandwidth low by forcing the lowest
|
||||
// rendition. User can still manually change quality via controls.
|
||||
ApplyInitialVideoQuality(
|
||||
|
||||
@@ -143,11 +143,33 @@ class MediaSessionPool(
|
||||
|
||||
reset(mediaSession, keepPlaying)
|
||||
|
||||
// Warm-pool fast path acquires a player that still holds its MediaItem, so the
|
||||
// client side skips setMediaItem (see GetVideoController) and onAddMediaItems
|
||||
// never fires for this fresh session — leaving the notification's tap target
|
||||
// unset. Re-bind it from the player's current item so tapping the playback
|
||||
// notification opens the originating nostr URI.
|
||||
bindSessionActivity(mediaSession, mediaSession.player.currentMediaItem)
|
||||
|
||||
cache.put(mediaSession.id, SessionListener(mediaSession, listener))
|
||||
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
fun bindSessionActivity(
|
||||
session: MediaSession,
|
||||
mediaItem: MediaItem?,
|
||||
) {
|
||||
val callbackUri = mediaItem?.mediaMetadata?.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI) ?: return
|
||||
session.setSessionActivity(
|
||||
PendingIntent.getActivity(
|
||||
appContext,
|
||||
0,
|
||||
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), appContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun releaseSession(session: MediaSession) {
|
||||
val listener = playingMap.get(session.id) ?: cache.get(session.id)
|
||||
if (listener != null) {
|
||||
@@ -220,16 +242,7 @@ class MediaSessionPool(
|
||||
mediaItems: List<MediaItem>,
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
// set up return call when clicking on the Notification bar
|
||||
mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString(MediaItemCache.EXTRA_CALLBACK_URI)?.let {
|
||||
mediaSession.setSessionActivity(
|
||||
PendingIntent.getActivity(
|
||||
appContext,
|
||||
0,
|
||||
Intent(Intent.ACTION_VIEW, it.toUri(), appContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
pool.bindSessionActivity(mediaSession, mediaItems.firstOrNull())
|
||||
|
||||
return Futures.immediateFuture(mediaItems)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.service
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
@@ -137,6 +140,29 @@ class PlaybackService : MediaSessionService() {
|
||||
Log.d("PlaybackService", "PlaybackService.onCreate")
|
||||
}
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int =
|
||||
try {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Media3's MediaSessionService.onStartCommand can call stopSelfSafely() when there is
|
||||
// no active playback to handle a delivered intent (e.g. a MEDIA_BUTTON from a headset
|
||||
// arriving while the app is backgrounded). stopSelfSafely() invokes startForeground()
|
||||
// to detach the foreground notification before stopping, which Android 12+ rejects
|
||||
// from the background with ForegroundServiceStartNotAllowedException. There is no
|
||||
// playback to keep alive in this path, so swallow it and stop the service.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
|
||||
Log.w("PlaybackService") { "Foreground service start not allowed; stopping PlaybackService" }
|
||||
stopSelf()
|
||||
START_NOT_STICKY
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d("PlaybackService", "PlaybackService.onDestroy")
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
|
||||
fun since(key: T) = latestEOSEs.since(user(key), list(key))
|
||||
|
||||
/**
|
||||
* Drops the EOSE state for the (user, list) pair this key resolves to so the next subscription
|
||||
* assembly will ask the relay from scratch instead of carrying over a stale `since` cursor.
|
||||
* Use when the in-memory note store may have evicted previously-loaded events (e.g.
|
||||
* [LargeSoftCache]'s WeakReference store under memory pressure) and a list switch needs to
|
||||
* re-fetch them rather than rely on a cache that's no longer there.
|
||||
*/
|
||||
protected fun clearEoseFor(key: T) {
|
||||
latestEOSEs.clear(user(key), list(key))
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
key: T,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
|
||||
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 +112,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)
|
||||
@@ -127,6 +130,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 +143,7 @@ class RelaySubscriptionsCoordinator(
|
||||
discovery,
|
||||
polls,
|
||||
pictures,
|
||||
calendars,
|
||||
products,
|
||||
shorts,
|
||||
publicChats,
|
||||
@@ -167,6 +174,7 @@ class RelaySubscriptionsCoordinator(
|
||||
relayInfoNip66,
|
||||
chess,
|
||||
nwc,
|
||||
onchainZaps,
|
||||
)
|
||||
|
||||
fun destroy() = all.forEach { it.destroy() }
|
||||
|
||||
@@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
|
||||
val SummaryKinds =
|
||||
listOf(
|
||||
@@ -62,6 +63,7 @@ val SummaryKinds =
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
)
|
||||
|
||||
val NotificationsPerKeyKinds =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
val RepliesAndReactionsKinds =
|
||||
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
OtsEvent.KIND,
|
||||
TextNoteModificationEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
|
||||
@@ -48,7 +48,7 @@ class UserFinderFilterAssembler(
|
||||
val group =
|
||||
listOf(
|
||||
UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys),
|
||||
UserWatcherSubAssembler(client, cache, ::allKeys),
|
||||
UserWatcherSubAssembler(client, cache, failureTracker, ::allKeys),
|
||||
UserReportsSubAssembler(client, cache, ::allKeys),
|
||||
UserCardsSubAssembler(client, cache, ::allKeys),
|
||||
)
|
||||
|
||||
@@ -20,12 +20,15 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
|
||||
@@ -107,16 +110,46 @@ class UserOutboxFinderSubAssembler(
|
||||
hasTried,
|
||||
)
|
||||
|
||||
return perRelayKeysBoth.mapNotNull {
|
||||
val sortedUsers = it.value.sorted()
|
||||
if (sortedUsers.isNotEmpty()) {
|
||||
RelayBasedFilter(
|
||||
relay = it.key,
|
||||
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
val activeFilters =
|
||||
perRelayKeysBoth.mapNotNull {
|
||||
val sortedUsers = it.value.sorted()
|
||||
if (sortedUsers.isNotEmpty()) {
|
||||
RelayBasedFilter(
|
||||
relay = it.key,
|
||||
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Users that pickRelaysToLoadUsers could not place anywhere this pass:
|
||||
// every candidate relay tier (outbox/hints/index/search/connected/common)
|
||||
// is already in hasTried. Without a fallback they'd be silently dropped
|
||||
// and a late-published kind 10002 would never reach the UI.
|
||||
val placedPubkeys =
|
||||
perRelayKeysBoth.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.toSet()
|
||||
val abandonedPubkeys =
|
||||
noOutboxList.mapNotNullTo(mutableSetOf<HexKey>()) { user ->
|
||||
user.pubkeyHex.takeIf { it !in placedPubkeys }
|
||||
}
|
||||
|
||||
if (abandonedPubkeys.isEmpty()) return activeFilters
|
||||
|
||||
val sortedAbandoned = abandonedPubkeys.sorted()
|
||||
val fallbackRelays =
|
||||
(DefaultIndexerRelayList + DefaultSearchRelayList) - failureTracker.cannotConnectRelays
|
||||
val fallbackFilters =
|
||||
fallbackRelays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter = Filter(kinds = relayListKinds, authors = sortedAbandoned),
|
||||
)
|
||||
}
|
||||
|
||||
return activeFilters + fallbackFilters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -43,11 +44,13 @@ val UserMetadataForKeyKinds =
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
KeyPackageRelayListEvent.KIND,
|
||||
PaymentTargetsEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterUserMetadataForKey(
|
||||
authors: Set<User>,
|
||||
indexRelays: Set<NormalizedRelayUrl>,
|
||||
cannotConnectRelays: Set<NormalizedRelayUrl>,
|
||||
since: EOSEAccountFast<User>,
|
||||
): List<RelayBasedFilter> {
|
||||
val perRelayUsers =
|
||||
@@ -57,7 +60,7 @@ fun filterUserMetadataForKey(
|
||||
key.outboxRelays()
|
||||
?: (key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays)
|
||||
|
||||
relays.forEach {
|
||||
(relays - cannotConnectRelays).forEach {
|
||||
add(it, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
class UserWatcherSubAssembler(
|
||||
client: INostrClient,
|
||||
val cache: LocalCache,
|
||||
val failureTracker: RelayOfflineTracker,
|
||||
allKeys: () -> Set<UserFinderQueryState>,
|
||||
) : BaseEoseManager<UserFinderQueryState>(client, allKeys) {
|
||||
/**
|
||||
@@ -99,7 +101,13 @@ class UserWatcherSubAssembler(
|
||||
)
|
||||
}
|
||||
|
||||
val newFilters = filterUserMetadataForKey(users, indexRelays, latestEOSEs).ifEmpty { null }
|
||||
val newFilters =
|
||||
filterUserMetadataForKey(
|
||||
users,
|
||||
indexRelays,
|
||||
failureTracker.cannotConnectRelays,
|
||||
latestEOSEs,
|
||||
).ifEmpty { null }
|
||||
|
||||
sub.updateFilters(newFilters?.groupByRelay())
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ open class EOSEByKey<U : Any>(
|
||||
|
||||
fun since(listCode: U) = followList[listCode]?.relayList
|
||||
|
||||
/** Drops the EOSE state for [listCode] so the next filter assembly starts fresh. */
|
||||
fun clear(listCode: U) {
|
||||
followList.remove(listCode)
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
listCode: U,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
@@ -83,6 +88,19 @@ open class EOSEAccountKey<U : Any>(
|
||||
users.remove(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the EOSE state for the (user, list) pair so the next assembly will ask the relay
|
||||
* from scratch. Used by feeds that need to force a re-fetch — e.g. calendar / pictures
|
||||
* where [LargeSoftCache]'s WeakReference store may have evicted the previously-loaded notes
|
||||
* while the user was viewing a different list.
|
||||
*/
|
||||
fun clear(
|
||||
user: User,
|
||||
listCode: U,
|
||||
) {
|
||||
users[user]?.clear(listCode)
|
||||
}
|
||||
|
||||
fun since(
|
||||
key: User,
|
||||
listCode: U,
|
||||
|
||||
@@ -122,7 +122,7 @@ object ScheduledPostNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
|
||||
@@ -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,13 +21,14 @@
|
||||
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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
@@ -64,8 +65,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
open class EditPostViewModel : ViewModel() {
|
||||
@@ -80,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
|
||||
@@ -114,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
|
||||
@@ -144,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 },
|
||||
@@ -186,86 +185,88 @@ open class EditPostViewModel : ViewModel() {
|
||||
context: Context,
|
||||
stripMetadata: Boolean = true,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.launchSigner {
|
||||
val myAccount = account
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launch
|
||||
val myMultiOrchestrator = multiOrchestrator ?: return@launchSigner
|
||||
|
||||
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
|
||||
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
alt,
|
||||
if (sensitiveContent) "" else null,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
try {
|
||||
val results =
|
||||
myMultiOrchestrator.upload(
|
||||
alt,
|
||||
if (sensitiveContent) "" else null,
|
||||
MediaCompressor.intToCompressorQuality(mediaQuality),
|
||||
server,
|
||||
myAccount,
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 =
|
||||
myAccount.createNip95(
|
||||
byteArray = state.result.bytes,
|
||||
headerInfo = state.result.fileHeader,
|
||||
alt = alt,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
if (results.allGood) {
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 =
|
||||
myAccount.createNip95(
|
||||
byteArray = state.result.bytes,
|
||||
headerInfo = state.result.fileHeader,
|
||||
alt = alt,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.fileHeader.thumbHash
|
||||
?.let { thumbhash(it.thumbhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
message = message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreview = findUrlInMessage()
|
||||
message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
this@EditPostViewModel.multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
this@EditPostViewModel.multiOrchestrator = null
|
||||
} else {
|
||||
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
|
||||
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
|
||||
}
|
||||
} finally {
|
||||
mediaUploadTracker.finishUpload()
|
||||
}
|
||||
|
||||
mediaUploadTracker.finishUpload()
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
subject = TextFieldValue("")
|
||||
|
||||
editedFromNote = null
|
||||
@@ -281,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
|
||||
@@ -306,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()
|
||||
|
||||
@@ -205,7 +205,7 @@ object MediaSaverToDisk {
|
||||
contentSource: BufferedSource,
|
||||
contentResolver: ContentResolver,
|
||||
) {
|
||||
val cleanMimeType = contentType.substringBefore(";").trim()
|
||||
val cleanMimeType = normalizeMimeTypeForMediaStore(contentType.substringBefore(";").trim())
|
||||
|
||||
val (masterUri, baseDir) =
|
||||
when {
|
||||
@@ -271,6 +271,15 @@ object MediaSaverToDisk {
|
||||
|
||||
private fun trimInlineMetaData(url: String): String = url.substringBefore("#")
|
||||
|
||||
// Android's MediaStore only accepts a fixed allow-list of MIME types.
|
||||
// MimeTypeMap returns variants like video/x-m4v that MediaProvider rejects,
|
||||
// so map them to the closest supported equivalent.
|
||||
internal fun normalizeMimeTypeForMediaStore(mimeType: String): String =
|
||||
when (mimeType.lowercase()) {
|
||||
"video/x-m4v" -> "video/mp4"
|
||||
else -> mimeType
|
||||
}
|
||||
|
||||
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
|
||||
private const val PDF_MIME_TYPE = "application/pdf"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -90,10 +91,11 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSucess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) = try {
|
||||
uploadUnsafe(context, onSucess, onError)
|
||||
uploadUnsafe(context, accountViewModel, onSucess, onError)
|
||||
} catch (e: SignerExceptions.ReadOnlyException) {
|
||||
onError(
|
||||
stringRes(context, R.string.read_only_user),
|
||||
@@ -103,6 +105,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun uploadUnsafe(
|
||||
context: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
onSucess: () -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
@@ -155,10 +158,13 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
// Sign + publish via launchSigner so SignerExceptions surface
|
||||
// through the standard toastManager pipeline (and timed-out /
|
||||
// rejected prompts get logged instead of crashing the process).
|
||||
val nip95jobs =
|
||||
nip95s.map {
|
||||
// upload each file as an individual nip95 event.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountViewModel.launchSigner {
|
||||
val nip95 = myAccount.createNip95(it.bytes, headerInfo = it.fileHeader, caption, if (sensitiveContent) "" else null)
|
||||
myAccount.consumeAndSendNip95(nip95.first, nip95.second)
|
||||
}
|
||||
@@ -166,9 +172,8 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
val videoJobs =
|
||||
videosAndOthers.map {
|
||||
// upload each file as an individual nip95 event.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account?.sendHeader(
|
||||
accountViewModel.launchSigner {
|
||||
myAccount.sendHeader(
|
||||
url = it.url,
|
||||
magnetUri = it.magnet,
|
||||
headerInfo = it.fileHeader,
|
||||
@@ -182,8 +187,8 @@ open class NewMediaModel : ViewModel() {
|
||||
val imageJobs =
|
||||
if (imageUrls.isNotEmpty()) {
|
||||
listOf(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account?.sendAllAsOnePictureEvent(
|
||||
accountViewModel.launchSigner {
|
||||
myAccount.sendAllAsOnePictureEvent(
|
||||
urlHeaderInfo = imageUrls,
|
||||
caption = caption,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
|
||||
@@ -110,7 +110,7 @@ fun NewMediaView(
|
||||
onClose()
|
||||
},
|
||||
onPost = {
|
||||
postViewModel.upload(context, onClose, accountViewModel.toastManager::toast)
|
||||
postViewModel.upload(context, accountViewModel, onClose, accountViewModel.toastManager::toast)
|
||||
postViewModel.selectedServer?.let {
|
||||
account.settings.changeDefaultFileServer(it)
|
||||
}
|
||||
|
||||
@@ -271,6 +271,38 @@ fun NewUserMetadataScreen(
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.lightning_address)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.lnAddress.value,
|
||||
onValueChange = { postViewModel.lnAddress.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "me@mylightningnode.com",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.lnurl)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.lnURL.value,
|
||||
onValueChange = { postViewModel.lnURL.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "LNURL1…",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
// -- Social Proofs --
|
||||
ExpandableSection(
|
||||
title = stringRes(R.string.social_proof),
|
||||
|
||||
@@ -57,6 +57,8 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
val website = mutableStateOf("")
|
||||
val pronouns = mutableStateOf("")
|
||||
val nip05 = mutableStateOf("")
|
||||
val lnAddress = mutableStateOf("")
|
||||
val lnURL = mutableStateOf("")
|
||||
|
||||
val twitter = mutableStateOf("")
|
||||
val github = mutableStateOf("")
|
||||
@@ -80,6 +82,8 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
website.value = it.info.website ?: ""
|
||||
pronouns.value = it.info.pronouns ?: ""
|
||||
nip05.value = it.info.nip05 ?: ""
|
||||
lnAddress.value = it.info.lud16 ?: ""
|
||||
lnURL.value = it.info.lud06 ?: ""
|
||||
}
|
||||
|
||||
twitter.value = ""
|
||||
@@ -117,6 +121,8 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
pronouns = pronouns.value,
|
||||
about = about.value,
|
||||
nip05 = nip05.value,
|
||||
lnAddress = lnAddress.value,
|
||||
lnURL = lnURL.value,
|
||||
)
|
||||
|
||||
val identities =
|
||||
@@ -140,6 +146,8 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
banner.value = ""
|
||||
website.value = ""
|
||||
nip05.value = ""
|
||||
lnAddress.value = ""
|
||||
lnURL.value = ""
|
||||
twitter.value = ""
|
||||
github.value = ""
|
||||
mastodon.value = ""
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.util.Patterns
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
data class RangesChanges(
|
||||
val original: TextRange,
|
||||
val modified: TextRange,
|
||||
)
|
||||
|
||||
class UrlUserTagTransformation(
|
||||
val color: Color,
|
||||
) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText = buildAnnotatedStringWithUrlHighlighting(text, color)
|
||||
}
|
||||
|
||||
fun buildAnnotatedStringWithUrlHighlighting(
|
||||
text: AnnotatedString,
|
||||
color: Color,
|
||||
): TransformedText {
|
||||
val substitutions = mutableListOf<RangesChanges>()
|
||||
|
||||
val newText =
|
||||
buildAnnotatedString {
|
||||
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
|
||||
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
|
||||
append(
|
||||
text.text.split('\n').joinToString("\n") { paragraph: String ->
|
||||
paragraph.split(' ').joinToString(" ") { word: String ->
|
||||
try {
|
||||
val mention = parseUserMention(word)
|
||||
if (mention != null) {
|
||||
val (keyPortion, restOfWord, hexKey) = mention
|
||||
|
||||
val startIndex = builderBefore.toString().length
|
||||
|
||||
builderBefore.append(
|
||||
"$keyPortion$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
|
||||
val endIndex = startIndex + keyPortion.length
|
||||
|
||||
val user = LocalCache.getOrCreateUser(hexKey)
|
||||
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
|
||||
builderAfter.append(
|
||||
"$newWord$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, startNew + newWord.length),
|
||||
),
|
||||
)
|
||||
newWord + restOfWord
|
||||
} else if (Patterns.WEB_URL.matcher(word).matches()) {
|
||||
val startIndex = builderBefore.toString().length
|
||||
val endIndex = startIndex + word.length
|
||||
|
||||
val startNew = builderAfter.toString().length
|
||||
val endNew = startNew + word.length
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, endNew),
|
||||
),
|
||||
)
|
||||
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
} else {
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
// if it can't parse the key, don't try to change.
|
||||
builderBefore.append("$word ")
|
||||
builderAfter.append("$word ")
|
||||
word
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
substitutions.forEach {
|
||||
addStyle(
|
||||
style =
|
||||
SpanStyle(
|
||||
color = color,
|
||||
textDecoration = TextDecoration.None,
|
||||
),
|
||||
start = it.modified.start,
|
||||
end = it.modified.end,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val numberOffsetTranslator =
|
||||
object : OffsetMapping {
|
||||
// Treat each substitution as an atomic wedge: any cursor position that falls
|
||||
// strictly inside a substituted range snaps to the wedge's trailing edge.
|
||||
// Without this, an IME (e.g. SwiftKey in extracted-text mode) can place the
|
||||
// cursor in the middle of an "@npub1..." mention, and a subsequent backspace
|
||||
// deletes a char from inside the bech32, breaking the npub and "expanding"
|
||||
// the collapsed mention.
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
val inInsideRange =
|
||||
substitutions.firstOrNull { offset > it.original.start && offset < it.original.end }
|
||||
|
||||
if (inInsideRange != null) {
|
||||
return inInsideRange.modified.end
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
|
||||
|
||||
return if (lastRangeThrough != null) {
|
||||
lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
val inInsideRange =
|
||||
substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end }
|
||||
|
||||
if (inInsideRange != null) {
|
||||
return inInsideRange.original.end
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
|
||||
|
||||
return if (lastRangeThrough != null) {
|
||||
lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
newText,
|
||||
numberOffsetTranslator,
|
||||
)
|
||||
}
|
||||
|
||||
private data class UserMention(
|
||||
val keyPortion: String,
|
||||
val restOfWord: String,
|
||||
val hexKey: String,
|
||||
)
|
||||
|
||||
private fun parseUserMention(word: String): UserMention? {
|
||||
var key = word
|
||||
val prefix: String
|
||||
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
prefix = key.substring(0, 6)
|
||||
key = key.substring(6)
|
||||
} else if (key.startsWith("@")) {
|
||||
prefix = "@"
|
||||
key = key.substring(1)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (key.startsWith("npub1", true) && key.length >= 63) {
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null
|
||||
return UserMention("$prefix$keyB32", restOfWord, hex)
|
||||
} else if (key.startsWith("nprofile1", true)) {
|
||||
val parsed = Nip19Parser.uriToRoute(key) ?: return null
|
||||
val entity = parsed.entity
|
||||
if (entity !is NProfile && entity !is NPub) return null
|
||||
val hex =
|
||||
when (entity) {
|
||||
is NProfile -> entity.hex
|
||||
is NPub -> entity.hex
|
||||
else -> return null
|
||||
}
|
||||
val bech32Len = parsed.nip19raw.length
|
||||
val restOfWord = key.substring(bech32Len)
|
||||
return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -38,6 +38,7 @@ enum class ServerType {
|
||||
val DEFAULT_MEDIA_SERVERS: List<ServerName> =
|
||||
listOf(
|
||||
ServerName("Nostr.Build", "https://blossom.band/", ServerType.Blossom),
|
||||
ServerName("Nostrcheck.me", "https://cdn.nostrcheck.me", ServerType.Blossom),
|
||||
ServerName("24242.io", "https://24242.io/", ServerType.Blossom),
|
||||
ServerName("Azzamo", "https://blossom.azzamo.media", ServerType.Blossom),
|
||||
ServerName("YakiHonne", "https://blossom.yakihonne.com/", ServerType.Blossom),
|
||||
|
||||
@@ -22,13 +22,24 @@ package com.vitorpamplona.amethyst.ui.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
fun hasPermission(
|
||||
context: Context,
|
||||
@@ -55,6 +66,17 @@ fun buildCallPermissions(isVideo: Boolean): Array<String> {
|
||||
return permissions.toTypedArray()
|
||||
}
|
||||
|
||||
fun openAppSettings(context: Context) {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCallWithPermission(
|
||||
context: Context,
|
||||
@@ -62,23 +84,50 @@ fun rememberCallWithPermission(
|
||||
onCall: () -> Unit,
|
||||
): () -> Unit {
|
||||
val permissions = remember(isVideo) { buildCallPermissions(isVideo) }
|
||||
var showDeniedDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { _ ->
|
||||
// Bluetooth is optional — proceed if core permissions are granted
|
||||
if (hasCallPermissions(context, isVideo)) onCall()
|
||||
// Bluetooth is optional — proceed if core permissions are granted.
|
||||
// If core permissions are still denied (including the silent
|
||||
// permanently-denied case where Android skips the dialog), surface
|
||||
// the deep-link dialog instead of failing silently.
|
||||
if (hasCallPermissions(context, isVideo)) {
|
||||
onCall()
|
||||
} else {
|
||||
showDeniedDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
val bluetoothLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { _ ->
|
||||
// BT result is best-effort and never blocks the call.
|
||||
}
|
||||
|
||||
if (showDeniedDialog) {
|
||||
CallPermissionDeniedDialog(
|
||||
isVideo = isVideo,
|
||||
onDismiss = { showDeniedDialog = false },
|
||||
onOpenSettings = {
|
||||
showDeniedDialog = false
|
||||
openAppSettings(context)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return remember(onCall, isVideo) {
|
||||
{
|
||||
if (hasCallPermissions(context, isVideo)) {
|
||||
// Core permissions granted; still request BT if missing
|
||||
// Core permissions granted; request BT separately so the
|
||||
// result callback doesn't double-fire onCall().
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
!hasPermission(context, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
) {
|
||||
launcher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT))
|
||||
bluetoothLauncher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT))
|
||||
}
|
||||
onCall()
|
||||
} else {
|
||||
@@ -87,3 +136,36 @@ fun rememberCallWithPermission(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallPermissionDeniedDialog(
|
||||
isVideo: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(R.string.call_permission_denied_title)) },
|
||||
text = {
|
||||
Text(
|
||||
stringRes(
|
||||
if (isVideo) {
|
||||
R.string.call_permission_denied_video
|
||||
} else {
|
||||
R.string.call_permission_denied_voice
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onOpenSettings) {
|
||||
Text(stringRes(R.string.call_permission_denied_open_settings))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringRes(R.string.call_permission_denied_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
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.richtext.NowhereLinkSegment
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
// Maps the first path segment of a nowhere URL to a localized label. The nowhere project ships
|
||||
// eight tools (event, fundraiser, store, petition, message, drop, art, forum) and the URL path
|
||||
// uses the first letter as a discriminator (e.g. nowhr.xyz/s#... is a store). Unknown codes
|
||||
// fall through to the generic "Nowhere site" label.
|
||||
private val nowhereToolLabels =
|
||||
mapOf(
|
||||
"e" to R.string.nowhere_link_card_event,
|
||||
"f" to R.string.nowhere_link_card_fundraiser,
|
||||
"s" to R.string.nowhere_link_card_store,
|
||||
"p" to R.string.nowhere_link_card_petition,
|
||||
"m" to R.string.nowhere_link_card_message,
|
||||
"d" to R.string.nowhere_link_card_drop,
|
||||
"a" to R.string.nowhere_link_card_art,
|
||||
"fo" to R.string.nowhere_link_card_forum,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NowhereLinkCard(segment: NowhereLinkSegment) {
|
||||
val uri = LocalUriHandler.current
|
||||
val titleRes = segment.tool?.lowercase()?.let { nowhereToolLabels[it] } ?: R.string.nowhere_link_card_generic
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.clickable {
|
||||
runCatching {
|
||||
val target = if (segment.segmentText.contains("://")) segment.segmentText else "https://${segment.segmentText}"
|
||||
uri.openUri(target)
|
||||
}
|
||||
}.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(titleRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.padding(top = 2.dp))
|
||||
|
||||
Text(
|
||||
text = segment.host,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
|
||||
@@ -481,6 +482,8 @@ private fun RenderWordWithoutPreview(
|
||||
|
||||
is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||
|
||||
is NowhereLinkSegment -> ClickableUrl(word.segmentText, word.segmentText)
|
||||
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
|
||||
// Don't offer to pay invoices
|
||||
@@ -530,6 +533,7 @@ private fun RenderWordWithPreview(
|
||||
is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is PdfSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel)
|
||||
is NowhereLinkSegment -> NowhereLinkCard(word)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel)
|
||||
is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* 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.namecoin
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Translate a [NamecoinResolveOutcome] from the quartz resolver into the
|
||||
* shared [NamecoinResolveState] used elsewhere in the app (e.g. the
|
||||
* desktop `SearchScreen`'s inline Namecoin lookup row).
|
||||
*
|
||||
* Mirrors the wording desktop already ships so the same identifier
|
||||
* produces the same diagnostic string regardless of which surface
|
||||
* triggered the lookup. Callers are expected to handle
|
||||
* [NamecoinResolveOutcome.Success] separately (it needs a [User]
|
||||
* lookup through [com.vitorpamplona.amethyst.model.LocalCache], which
|
||||
* this helper has no access to).
|
||||
*/
|
||||
fun mapOutcomeToResolveState(outcome: NamecoinResolveOutcome): NamecoinResolveState =
|
||||
when (outcome) {
|
||||
is NamecoinResolveOutcome.Success ->
|
||||
// Success is intentionally NOT handled here — callers must
|
||||
// resolve the pubkey through LocalCache first.
|
||||
error("mapOutcomeToResolveState called with Success outcome; resolve via LocalCache instead")
|
||||
|
||||
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound
|
||||
|
||||
is NamecoinResolveOutcome.NoNostrField ->
|
||||
NamecoinResolveState.Error("${outcome.name} is registered but has no Nostr pubkey")
|
||||
|
||||
is NamecoinResolveOutcome.MalformedRecord ->
|
||||
// Surface the parser detail verbatim so the publisher of the
|
||||
// broken record can locate the bad byte
|
||||
// (kotlinx.serialization includes a column number).
|
||||
NamecoinResolveState.Error("${outcome.name} record is malformed: ${outcome.error}")
|
||||
|
||||
is NamecoinResolveOutcome.ServersUnreachable ->
|
||||
NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again")
|
||||
|
||||
is NamecoinResolveOutcome.InvalidIdentifier ->
|
||||
NamecoinResolveState.Error("Invalid Namecoin identifier")
|
||||
|
||||
NamecoinResolveOutcome.Timeout ->
|
||||
NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again")
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight syntactic check: does this look like something we should
|
||||
* route to Namecoin? Mirrors [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver.isNamecoinIdentifier]
|
||||
* but tolerates a leading `@` (matches the dropdown's [com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState.userSearchTermOrNull]).
|
||||
*/
|
||||
fun looksLikeNamecoinIdentifier(raw: String): Boolean {
|
||||
val trimmed = raw.trim().removePrefix("@").lowercase()
|
||||
if (trimmed.length < 5) return false
|
||||
return trimmed.endsWith(".bit") ||
|
||||
trimmed.contains("@") && trimmed.substringAfter('@').endsWith(".bit")
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Namecoin resolution indicator + result row. Designed to be
|
||||
* mounted alongside any text input whose local-cache prefix search can
|
||||
* race ahead of an on-chain `.bit` lookup (the onchain-zap recipient
|
||||
* field and the global search bar both have this race).
|
||||
*
|
||||
* Behaviour:
|
||||
* - Renders nothing when [searchInput] is not a `.bit` identifier.
|
||||
* - Shows a small spinner row ("Resolving on Namecoin…") while the
|
||||
* ElectrumX lookup is in flight (after a 300 ms debounce to match
|
||||
* typical input-field debounce intervals).
|
||||
* - On success, shows the resolved user as a tappable row with a
|
||||
* `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls
|
||||
* [onUserResolved].
|
||||
* - On failure, shows a single explanatory line in the error colour.
|
||||
*
|
||||
* State is held in [NamecoinResolveState] (the same sealed class the
|
||||
* desktop `SearchScreen` and `NamecoinNameService` already use) so this
|
||||
* row stays in lockstep with the rest of the app's Namecoin UI.
|
||||
*
|
||||
* The composable is intentionally self-contained: it owns its own
|
||||
* [LaunchedEffect] keyed on [searchInput], so it cancels in-flight
|
||||
* lookups whenever the user keeps typing.
|
||||
*
|
||||
* @param modifier applied to the outer `Column` so callers can position
|
||||
* or pad the row (e.g. the search bar pads horizontally).
|
||||
*/
|
||||
@Composable
|
||||
fun NamecoinResolutionRow(
|
||||
searchInput: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onUserResolved: (User) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") }
|
||||
if (!looksLikeNamecoinIdentifier(trimmed)) return
|
||||
|
||||
var state by remember { mutableStateOf<NamecoinResolveState?>(null) }
|
||||
|
||||
LaunchedEffect(trimmed) {
|
||||
// Match UserSuggestionState's 300 ms debounce so we don't fire a
|
||||
// lookup on every keystroke.
|
||||
delay(300)
|
||||
state = NamecoinResolveState.Loading
|
||||
val outcome =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Amethyst.instance.namecoinResolver.resolveDetailed(trimmed)
|
||||
}.getOrElse {
|
||||
NamecoinResolveOutcome.ServersUnreachable(
|
||||
it.message ?: it::class.simpleName ?: "Lookup error",
|
||||
)
|
||||
}
|
||||
}
|
||||
state =
|
||||
when (outcome) {
|
||||
is NamecoinResolveOutcome.Success -> NamecoinResolveState.Resolved(outcome.result)
|
||||
else -> mapOutcomeToResolveState(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
when (val s = state) {
|
||||
null, NamecoinResolveState.Loading -> ResolvingChip(trimmed)
|
||||
is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved)
|
||||
NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.")
|
||||
is NamecoinResolveState.Error -> FailedRow(s.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResolvingChip(query: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Resolving $query on Namecoin…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResolvedRow(
|
||||
query: String,
|
||||
state: NamecoinResolveState.Resolved,
|
||||
accountViewModel: AccountViewModel,
|
||||
onUserResolved: (User) -> Unit,
|
||||
) {
|
||||
// Look up the User in the same cache the rest of the app uses, exactly
|
||||
// the way desktop's SearchScreen does. Falls back to a malformed-record
|
||||
// error row if the pubkey somehow fails the hex shape check.
|
||||
val user =
|
||||
remember(state.result.pubkey) {
|
||||
accountViewModel.account.cache.checkGetOrCreateUser(state.result.pubkey)
|
||||
}
|
||||
if (user == null) {
|
||||
FailedRow(
|
||||
"${state.result.namecoinName} record is malformed: " +
|
||||
"pubkey ${state.result.pubkey} is not a valid hex key",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onUserResolved(user) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
size = 32.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = user.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = query,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
NamecoinBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NamecoinBadge() {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Namecoin",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailedRow(message: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Text composable that opens [onClick] on a tap and copies [copyValue] to the
|
||||
* system clipboard on a long-press (with a Toast confirmation).
|
||||
*
|
||||
* This is the long-press-to-copy counterpart of [com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary].
|
||||
* It deliberately uses a plain [Text] + [combinedClickable] outer modifier
|
||||
* rather than an inline `LinkAnnotation.Clickable` inside an `AnnotatedString`,
|
||||
* because annotation-level clicks can't see a parent [combinedClickable]'s
|
||||
* long-press: the parent's tap area sits above the annotation's hit-test
|
||||
* region and would consume the tap before the annotation could fire it.
|
||||
*
|
||||
* @param displayText text shown to the user (may be a stripped form, e.g.
|
||||
* "example.com" for a website value "https://example.com").
|
||||
* @param copyValue raw value placed on the clipboard on long-press
|
||||
* (typically the full, unmodified profile field).
|
||||
* @param onClick tap action — usually "open the link" or "expand zap UI".
|
||||
* @param toastResId string resource shown via [Toast.LENGTH_SHORT] after the
|
||||
* value is placed on the clipboard. Defaults to a generic
|
||||
* "Copied to clipboard" message.
|
||||
* @param onLongClickLabelResId accessibility label exposed to TalkBack for
|
||||
* the long-press action. Defaults to "Copy to clipboard".
|
||||
*/
|
||||
@Composable
|
||||
fun LongPressCopyText(
|
||||
displayText: String,
|
||||
copyValue: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Ellipsis,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
toastResId: Int = R.string.copied_to_clipboard,
|
||||
onLongClickLabelResId: Int = R.string.copy_to_clipboard,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val longClickLabel = stringRes(onLongClickLabelResId)
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
color = color,
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
modifier =
|
||||
modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
clipboard.setText(copyValue)
|
||||
Toast.makeText(context, stringRes(context, toastResId), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onLongClickLabel = longClickLabel,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -164,6 +171,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsSc
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen
|
||||
@@ -178,6 +186,7 @@ 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
|
||||
@@ -250,6 +259,17 @@ 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.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) }
|
||||
@@ -266,6 +286,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) }
|
||||
|
||||
@@ -326,6 +347,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
|
||||
@@ -349,6 +371,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) }
|
||||
@@ -364,7 +387,15 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MarmotGroupChat> {
|
||||
MarmotGroupChatScreen(
|
||||
nostrGroupId = it.nostrGroupId,
|
||||
draftMessage = it.message,
|
||||
replyToInnerNote = it.replyId,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
composableFromEndArgs<Route.MarmotGroupInfo> { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) }
|
||||
|
||||
composableFromBottom<Route.CreateMarmotGroup> { CreateGroupScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -50,6 +50,8 @@ enum class NavBarItem {
|
||||
COMMUNITIES,
|
||||
ARTICLES,
|
||||
PICTURES,
|
||||
CALENDARS,
|
||||
CALENDAR_COLLECTIONS,
|
||||
SHORTS,
|
||||
PUBLIC_CHATS,
|
||||
FOLLOW_PACKS,
|
||||
@@ -199,6 +201,20 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Photo,
|
||||
resolveRoute = { Route.Pictures },
|
||||
),
|
||||
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 +334,8 @@ val DrawerFeedsItems: List<NavBarItem> =
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
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,45 @@ sealed class Route {
|
||||
|
||||
@Serializable object Pictures : 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 +150,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object WalletTransactions : Route()
|
||||
|
||||
@Serializable object OnchainTransactions : Route()
|
||||
|
||||
@Serializable
|
||||
data class WalletDetail(
|
||||
val walletId: String,
|
||||
@@ -239,6 +280,10 @@ sealed class Route {
|
||||
|
||||
@Serializable object CallSettings : Route()
|
||||
|
||||
@Serializable object NotificationSettings : Route()
|
||||
|
||||
@Serializable object CalendarReminderSettings : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
@@ -305,6 +350,10 @@ sealed class Route {
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ContactListUsers(
|
||||
val noteId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class Hashtag(
|
||||
val hashtag: String,
|
||||
) : Route()
|
||||
@@ -400,6 +449,8 @@ sealed class Route {
|
||||
|
||||
@Serializable data class MarmotGroupChat(
|
||||
val nostrGroupId: String,
|
||||
val message: String? = null,
|
||||
val replyId: HexKey? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class MarmotGroupInfo(
|
||||
|
||||
@@ -334,13 +334,13 @@ private enum class FeedGroup(
|
||||
@param:androidx.annotation.StringRes val labelRes: Int,
|
||||
) {
|
||||
FEEDS(R.string.feed_group_feeds),
|
||||
RELAYS(R.string.feed_group_relays),
|
||||
HASHTAGS(R.string.feed_group_hashtags),
|
||||
INTEREST_SETS(R.string.feed_group_interest_sets),
|
||||
COMMUNITIES(R.string.feed_group_communities),
|
||||
LOCATIONS(R.string.feed_group_locations),
|
||||
LISTS(R.string.feed_group_lists),
|
||||
DVMS(R.string.feed_group_dvms),
|
||||
RELAYS(R.string.feed_group_relays),
|
||||
COMMUNITIES(R.string.feed_group_communities),
|
||||
LISTS(R.string.feed_group_lists),
|
||||
}
|
||||
|
||||
private fun FeedDefinition.group(): FeedGroup =
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -107,6 +108,23 @@ fun HiddenNotePreview() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun HiddenNoteExcessiveHashtagsPreview() {
|
||||
ThemeComparisonColumn(
|
||||
toPreview = {
|
||||
HiddenNote(
|
||||
reports = persistentSetOf(),
|
||||
isHiddenAuthor = false,
|
||||
hasExcessiveHashtags = true,
|
||||
hashtagLimit = 8,
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
nav = EmptyNav(),
|
||||
) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HiddenNote(
|
||||
@@ -115,8 +133,11 @@ fun HiddenNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
nav: INav,
|
||||
hasExcessiveHashtags: Boolean = false,
|
||||
hashtagLimit: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val hasReporters = isHiddenAuthor || reports.isNotEmpty()
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
@@ -127,26 +148,42 @@ fun HiddenNote(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(30.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
|
||||
color = Color.Gray,
|
||||
)
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
if (isHiddenAuthor) {
|
||||
UserPicture(
|
||||
user = accountViewModel.userProfile(),
|
||||
size = Size35dp,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
baseNote = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
if (hasExcessiveHashtags) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.post_was_hidden_due_to_too_many_hashtags, hashtagLimit, hashtagLimit),
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (hasReporters || !hasExcessiveHashtags) {
|
||||
Text(
|
||||
text = stringRes(R.string.post_was_flagged_as_inappropriate_by),
|
||||
color = Color.Gray,
|
||||
modifier =
|
||||
if (hasExcessiveHashtags) {
|
||||
Modifier.padding(top = 10.dp)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
if (isHiddenAuthor) {
|
||||
UserPicture(
|
||||
user = accountViewModel.userProfile(),
|
||||
size = Size35dp,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
baseNote = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,11 +77,13 @@ fun WatchBlockAndReport(
|
||||
normalNote(isHidden.canPreview)
|
||||
} else {
|
||||
HiddenNote(
|
||||
isHidden.relevantReports,
|
||||
isHidden.isHiddenAuthor,
|
||||
accountViewModel,
|
||||
modifier,
|
||||
nav,
|
||||
reports = isHidden.relevantReports,
|
||||
isHiddenAuthor = isHidden.isHiddenAuthor,
|
||||
hasExcessiveHashtags = isHidden.hasExcessiveHashtags,
|
||||
hashtagLimit = isHidden.hashtagLimit,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = modifier,
|
||||
nav = nav,
|
||||
onClick = { showAnyway.value = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -149,6 +152,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryRespo
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
|
||||
@@ -212,6 +216,7 @@ 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
|
||||
@@ -253,6 +258,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
||||
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
|
||||
@@ -294,6 +301,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -490,9 +498,12 @@ fun calculateBackgroundColor(
|
||||
val defaultBackgroundColor = MaterialTheme.colorScheme.background
|
||||
val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor
|
||||
|
||||
// Only fade in/out the "new item" highlight for items that track read state.
|
||||
// Inner notes (reposts/quotes) pass routeForLastRead = null and reuse the parent color directly,
|
||||
// so the LaunchedEffect would just park a coroutine for 5s per item during scroll.
|
||||
// Inner notes (reposts) pass routeForLastRead = null with the parent's bgColor state;
|
||||
// share it directly so the inner highlight fades in lockstep with the outer.
|
||||
if (routeForLastRead == null && parentBackgroundColor != null) {
|
||||
return parentBackgroundColor
|
||||
}
|
||||
|
||||
val isNew =
|
||||
remember(createdAt, routeForLastRead) {
|
||||
routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
|
||||
@@ -945,6 +956,10 @@ private fun RenderNoteRow(
|
||||
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is OnchainZapEvent -> {
|
||||
RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is LiveActivitiesClipEvent -> {
|
||||
RenderChatClip(baseNote, accountViewModel, nav)
|
||||
}
|
||||
@@ -961,6 +976,10 @@ private fun RenderNoteRow(
|
||||
DisplayFollowList(baseNote, true, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is ContactListEvent -> {
|
||||
DisplayContactList(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is RelaySetEvent -> {
|
||||
DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
@@ -1180,6 +1199,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)
|
||||
}
|
||||
@@ -1481,14 +1508,22 @@ fun RenderRepost(
|
||||
}
|
||||
}
|
||||
|
||||
fun getGradient(backgroundColor: MutableState<Color>): Brush =
|
||||
Brush.verticalGradient(
|
||||
@Composable
|
||||
fun getGradient(backgroundColor: MutableState<Color>): Brush {
|
||||
val solid =
|
||||
if (backgroundColor.value.alpha == 0f) {
|
||||
MaterialTheme.colorScheme.background
|
||||
} else {
|
||||
backgroundColor.value.copy(alpha = 1f)
|
||||
}
|
||||
return Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
backgroundColor.value.copy(alpha = 0f),
|
||||
backgroundColor.value.copy(alpha = 1f),
|
||||
solid.copy(alpha = 0f),
|
||||
solid,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReplyNoteComposition(
|
||||
|
||||
@@ -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,155 @@
|
||||
/*
|
||||
* 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.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 com.vitorpamplona.amethyst.commons.model.OnchainZapEntry
|
||||
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, so memoize the list snapshot.
|
||||
val zapsState by observeNoteZaps(baseNote, accountViewModel)
|
||||
val entries =
|
||||
remember(zapsState) {
|
||||
zapsState
|
||||
?.note
|
||||
?.onchainZaps
|
||||
?.values
|
||||
?.toImmutableList() ?: persistentListOf()
|
||||
}
|
||||
|
||||
if (entries.isNotEmpty()) {
|
||||
RenderOnchainZapGallery(entries, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@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 amountText =
|
||||
remember(entry.verifiedSats) {
|
||||
showAmount(BigDecimal.valueOf(entry.verifiedSats))
|
||||
}
|
||||
val avatarAlpha = if (entry.confirmed) 1f else 0.6f
|
||||
|
||||
Box(
|
||||
modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
// Only the avatar dims for pending entries. The amount overlay and clock
|
||||
// badge stay at full opacity so they remain readable.
|
||||
Box(modifier = Modifier.alpha(avatarAlpha)) {
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
|
||||
user,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
CrossfadeToDisplayAmount(amountText)
|
||||
|
||||
if (!entry.confirmed) {
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -597,6 +548,7 @@ private fun ReactionDetailGallery(
|
||||
) {
|
||||
Column {
|
||||
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
|
||||
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
|
||||
}
|
||||
@@ -1171,6 +1123,11 @@ private fun likeClick(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private data class OnchainZapRequest(
|
||||
val amountSats: Long?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
|
||||
fun ZapReaction(
|
||||
@@ -1185,6 +1142,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 +1217,10 @@ fun ZapReaction(
|
||||
nav.nav(Route.UpdateZapAmount())
|
||||
}
|
||||
},
|
||||
onOnchainAmount = { amount ->
|
||||
wantsToZap = false
|
||||
onchainZapRequest = OnchainZapRequest(amount)
|
||||
},
|
||||
onError = { _, message, user ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
@@ -1280,6 +1244,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 +1859,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 +1896,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 +1908,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 +1933,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 ""
|
||||
|
||||
@@ -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 = "10000, 50000, 250000",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = postViewModel::addOnchainAmount,
|
||||
shape = ButtonBorder,
|
||||
enabled = postViewModel.nextOnchainAmount.text.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AddCircle,
|
||||
contentDescription = stringRes(R.string.add),
|
||||
modifier = Size20Modifier,
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Section 4: Nostr Wallet Connect ───────────────────────────────────
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
|
||||
@@ -41,6 +41,8 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
|
||||
var nextAmount by mutableStateOf(TextFieldValue(""))
|
||||
var amountSet by mutableStateOf(listOf<Long>())
|
||||
var nextOnchainAmount by mutableStateOf(TextFieldValue(""))
|
||||
var onchainAmountSet by mutableStateOf(listOf<Long>())
|
||||
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
|
||||
@@ -59,6 +61,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
|
||||
fun load() {
|
||||
this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value
|
||||
this.onchainAmountSet = accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value
|
||||
this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value
|
||||
|
||||
val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest()
|
||||
@@ -83,6 +86,19 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
amountSet = amountSet - amount
|
||||
}
|
||||
|
||||
fun addOnchainAmount() {
|
||||
val newValue = nextOnchainAmount.text.trim().toLongOrNull()
|
||||
if (newValue != null) {
|
||||
onchainAmountSet = onchainAmountSet + newValue
|
||||
}
|
||||
|
||||
nextOnchainAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun removeOnchainAmount(amount: Long) {
|
||||
onchainAmountSet = onchainAmountSet - amount
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
accountViewModel.launchSigner {
|
||||
sendPostSuspend()
|
||||
@@ -116,13 +132,15 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
null
|
||||
}
|
||||
|
||||
accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
|
||||
accountViewModel.account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update)
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
nextOnchainAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
nextAmount = TextFieldValue("")
|
||||
nextOnchainAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
@@ -130,6 +148,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
return (
|
||||
selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value ||
|
||||
amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value ||
|
||||
onchainAmountSet != accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value ||
|
||||
walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") ||
|
||||
walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") ||
|
||||
walletConnectSecret.text != (defaultUri?.secret ?: "")
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
@@ -89,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -157,6 +160,11 @@ fun ZapCustomDialog(
|
||||
|
||||
val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() }
|
||||
|
||||
// True while the on-chain hand-off sheet is open. Dismissing the on-chain
|
||||
// sheet closes the whole zap flow — the user has already committed to a
|
||||
// payment method, no "go back to Lightning" is offered here.
|
||||
var sendOnchain by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties =
|
||||
@@ -326,9 +334,39 @@ fun ZapCustomDialog(
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Hand off to the on-chain dialog with the entered amount +
|
||||
// message prefilled. Disabled while the amount is empty so the
|
||||
// user can't open a sheet that immediately gates on its own
|
||||
// empty field.
|
||||
TextButton(
|
||||
onClick = { sendOnchain = true },
|
||||
enabled = postViewModel.canSend() && !baseNote.isDraft(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.send_onchain_instead))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sendOnchain) {
|
||||
val zappedEventHint = remember(baseNote) { baseNote.toEventHint<Event>() }
|
||||
OnchainZapSendDialog(
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = {
|
||||
sendOnchain = false
|
||||
onClose()
|
||||
},
|
||||
recipientPubKey = baseNote.author?.pubkeyHex,
|
||||
zappedEvent = zappedEventHint,
|
||||
prefillAmountSats = postViewModel.value(),
|
||||
prefillComment = postViewModel.customMessage.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.location
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -30,7 +32,10 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
|
||||
import com.vitorpamplona.amethyst.service.location.toGeoHash
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val MAX_GEOLOCATE_RETRIES = 5
|
||||
|
||||
@Composable
|
||||
fun LoadCityName(
|
||||
@@ -40,34 +45,50 @@ fun LoadCityName(
|
||||
) {
|
||||
var cityName by remember(geohashStr) { mutableStateOf(CachedReversedGeoLocations.cached(geohashStr)) }
|
||||
|
||||
if (cityName == null) {
|
||||
if (onLoading != null) {
|
||||
onLoading()
|
||||
}
|
||||
val resolved = cityName
|
||||
if (resolved != null) {
|
||||
content(resolved)
|
||||
} else if (!CachedReversedGeoLocations.isGeocoderAvailable()) {
|
||||
// Devices without a system Geocoder backend (e.g. AOSP / GrapheneOS
|
||||
// without Google Play Services or microG) cannot reverse-geocode at
|
||||
// all. Show the geohash directly instead of spinning forever.
|
||||
content(geohashStr)
|
||||
} else {
|
||||
onLoading?.invoke()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(key1 = geohashStr, context) {
|
||||
LaunchedEffect(geohashStr, context) {
|
||||
val location = runCatching { geohashStr.toGeoHash() }.getOrNull()?.toLocation()
|
||||
if (location != null) {
|
||||
launch {
|
||||
var notReady = true
|
||||
var myStep = 1000L
|
||||
while (notReady) {
|
||||
// Retries while the Reverse Geolocation service is offline.
|
||||
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { newCityName ->
|
||||
if (newCityName != cityName) {
|
||||
notReady = false
|
||||
cityName = newCityName
|
||||
}
|
||||
}
|
||||
myStep = myStep * 2L
|
||||
delay(myStep)
|
||||
}
|
||||
}
|
||||
if (location == null) {
|
||||
cityName = geohashStr
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var delayMs = 1000L
|
||||
repeat(MAX_GEOLOCATE_RETRIES) {
|
||||
val result = geoLocateOnce(geohashStr, location, context)
|
||||
if (result != null) {
|
||||
cityName = result
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(delayMs)
|
||||
delayMs *= 2L
|
||||
}
|
||||
// Geocoder kept returning errors — give up and render the geohash
|
||||
// so the loading indicator stops.
|
||||
cityName = geohashStr
|
||||
}
|
||||
} else {
|
||||
cityName?.let { content(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun geoLocateOnce(
|
||||
geohashStr: String,
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { result ->
|
||||
if (cont.isActive) cont.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -41,7 +40,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -123,9 +124,11 @@ fun ForwardZapTo(
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.forwardZapToEditting.value,
|
||||
onValueChange = { postViewModel.updateZapForwardTo(it) },
|
||||
OutlinedThinPaddingTextField(
|
||||
state = postViewModel.forwardZapToEditting,
|
||||
onTextChanged = postViewModel::onForwardZapTextChanged,
|
||||
inputTransformation = MentionPreservingInputTransformation,
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
label = { Text(text = stringRes(R.string.zap_split_search_and_add_user)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
@@ -135,10 +138,6 @@ fun ForwardZapTo(
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.zapsplits
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
@Stable
|
||||
interface IZapField {
|
||||
val forwardZapTo: MutableState<SplitBuilder<User>>
|
||||
val forwardZapToEditting: MutableState<TextFieldValue>
|
||||
val forwardZapToEditting: TextFieldState
|
||||
|
||||
fun updateZapFromText()
|
||||
|
||||
@@ -37,5 +37,5 @@ interface IZapField {
|
||||
sliderValue: Float,
|
||||
)
|
||||
|
||||
fun updateZapForwardTo(newZapForwardTo: TextFieldValue)
|
||||
fun onForwardZapTextChanged()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user